diff --git a/.browserslistrc b/.browserslistrc index 427441dc9..6784945a5 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -8,10 +8,4 @@ # You can see what browsers were selected by your queries by running: # npx browserslist -last 1 Chrome version -last 1 Firefox version -last 2 Edge major versions -last 2 Safari major versions -last 2 iOS major versions -Firefox ESR -not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. +defaults \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index c24677846..c82009e40 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,7 @@ # Editor configuration, see https://editorconfig.org root = true + [*] charset = utf-8 indent_style = space @@ -22,3 +23,7 @@ indent_size = 2 [*.csproj] indent_size = 2 + +[*.cs] +# Disable SonarLint warning S1075 (Don't use hardcoded url) +dotnet_diagnostic.S1075.severity = none diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4331abb49..805c3b61d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,10 +25,10 @@ body: - type: dropdown id: version attributes: - label: Kavita Version Number - If you don not see your version number listed, please update Kavita and see if your issue still persists. + label: Kavita Version Number - If you don't see your version number listed, please update Kavita and see if your issue still persists. multiple: false options: - - 0.8.3 - Stable + - 0.8.7 - Stable - Nightly Testing Branch validations: required: true diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 6aaef02d9..044864734 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -17,7 +17,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Install Swashbuckle CLI shell: powershell diff --git a/.github/workflows/canary-workflow.yml b/.github/workflows/canary-workflow.yml index 57ec316e4..b919030b0 100644 --- a/.github/workflows/canary-workflow.yml +++ b/.github/workflows/canary-workflow.yml @@ -9,7 +9,7 @@ on: jobs: build: name: Upload Kavita.Common for Version Bump - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout Repo uses: actions/checkout@v4 @@ -24,7 +24,7 @@ jobs: version: name: Bump version needs: [ build ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 with: @@ -33,7 +33,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Bump versions uses: SiqiLu/dotnet-bump-version@2.0.0 @@ -45,7 +45,7 @@ jobs: canary: name: Build Canary Docker needs: [ build, version ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: packages: write contents: read @@ -98,7 +98,7 @@ jobs: - name: Compile dotnet app uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Install Swashbuckle CLI run: dotnet tool install -g Swashbuckle.AspNetCore.Cli diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 391776dd8..7ce4276bc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,7 +13,7 @@ name: "CodeQL" on: push: - branches: [ "develop", "main" ] + branches: [ "develop"] pull_request: # The branches below must be a subset of the branches above branches: [ "develop" ] @@ -38,7 +38,7 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'csharp', 'javascript-typescript', 'python' ] + language: [ 'csharp', 'javascript-typescript' ] # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both @@ -48,9 +48,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Swashbuckle CLI - shell: bash - run: dotnet tool install -g Swashbuckle.AspNetCore.Cli + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/develop-workflow.yml b/.github/workflows/develop-workflow.yml index b7f229b14..006127645 100644 --- a/.github/workflows/develop-workflow.yml +++ b/.github/workflows/develop-workflow.yml @@ -7,7 +7,7 @@ on: jobs: debug: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Debug Info run: | @@ -17,7 +17,7 @@ jobs: echo "Matches Develop: ${{ github.ref == 'refs/heads/develop' }}" build: name: Upload Kavita.Common for Version Bump - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.ref == 'refs/heads/develop' steps: - name: Checkout Repo @@ -33,7 +33,7 @@ jobs: version: name: Bump version needs: [ build ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.ref == 'refs/heads/develop' steps: - uses: actions/checkout@v4 @@ -43,7 +43,7 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Bump versions uses: majora2007/dotnet-bump-version@v0.0.10 @@ -55,7 +55,7 @@ jobs: develop: name: Build Nightly Docker needs: [ build, version ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.ref == 'refs/heads/develop' permissions: packages: write @@ -128,7 +128,7 @@ jobs: - name: Compile dotnet app uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Install Swashbuckle CLI run: dotnet tool install -g Swashbuckle.AspNetCore.Cli @@ -137,6 +137,7 @@ jobs: - name: Login to Docker Hub uses: docker/login-action@v3 + if: ${{ github.repository_owner == 'Kareadita' }} with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} @@ -155,20 +156,33 @@ jobs: id: buildx uses: docker/setup-buildx-action@v3 + - name: Extract metadata (tags, labels) for Docker + id: docker_meta_nightly + uses: docker/metadata-action@v5 + with: + tags: | + type=raw,value=nightly + type=raw,value=nightly-${{ steps.parse-version.outputs.VERSION }} + images: | + name=jvmilazz0/kavita,enable=${{ github.repository_owner == 'Kareadita' }} + name=ghcr.io/${{ github.repository }} + - name: Build and push id: docker_build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true - tags: jvmilazz0/kavita:nightly, jvmilazz0/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:nightly, ghcr.io/kareadita/kavita:nightly-${{ steps.parse-version.outputs.VERSION }} + tags: ${{ steps.docker_meta_nightly.outputs.tags }} + labels: ${{ steps.docker_meta_nightly.outputs.labels }} - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} - name: Notify Discord uses: rjstone/discord-webhook-notify@v1 + if: ${{ github.repository_owner == 'Kareadita' }} with: severity: info description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }} diff --git a/.github/workflows/openapi-gen.yml b/.github/workflows/openapi-gen.yml new file mode 100644 index 000000000..45446d045 --- /dev/null +++ b/.github/workflows/openapi-gen.yml @@ -0,0 +1,68 @@ +name: Generate OpenAPI Documentation + +on: + push: + branches: [ 'develop', '!release/**' ] + paths: + - '**/*.cs' + - '**/*.csproj' + pull_request: + branches: [ 'develop', '!release/**' ] + workflow_dispatch: + +jobs: + generate-openapi: + runs-on: ubuntu-latest + # Only run on direct pushes to develop, not PRs + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.repository_owner == 'Kareadita' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Install dependencies + run: dotnet restore + + - name: Build project + run: dotnet build API/API.csproj --configuration Debug + + - name: Get Swashbuckle version + id: swashbuckle-version + run: | + VERSION=$(grep -o '> $GITHUB_OUTPUT + echo "Found Swashbuckle.AspNetCore version: $VERSION" + + - name: Install matching Swashbuckle CLI tool + run: | + dotnet new tool-manifest --force + dotnet tool install Swashbuckle.AspNetCore.Cli --version ${{ steps.swashbuckle-version.outputs.VERSION }} + + - name: Generate OpenAPI file + run: dotnet swagger tofile --output openapi.json API/bin/Debug/net9.0/API.dll v1 + + - name: Check for changes + id: git-check + run: | + git add openapi.json + git diff --staged --quiet openapi.json || echo "has_changes=true" >> $GITHUB_OUTPUT + + - name: Commit and push if changed + if: steps.git-check.outputs.has_changes == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + git commit -m "Update OpenAPI documentation" openapi.json + + # Pull latest changes with rebase to avoid merge commits + git pull --rebase origin develop + + git push + env: + GITHUB_TOKEN: ${{ secrets.REPO_GHA_PAT }} diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 7482deb0b..51589221f 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -1,15 +1,13 @@ name: Validate PR Body on: - push: - branches: '**' pull_request: branches: [ main, develop, canary ] types: [synchronize] jobs: check_pr: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Extract branch name shell: bash diff --git a/.github/workflows/release-workflow.yml.bak b/.github/workflows/release-workflow.yml similarity index 74% rename from .github/workflows/release-workflow.yml.bak rename to .github/workflows/release-workflow.yml index 251683b6c..757ce1075 100644 --- a/.github/workflows/release-workflow.yml.bak +++ b/.github/workflows/release-workflow.yml @@ -10,7 +10,7 @@ on: jobs: debug: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Debug Info run: | @@ -20,13 +20,13 @@ jobs: echo "Matches Develop: ${{ github.ref == 'refs/heads/develop' }}" if_merged: if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - run: | echo The PR was merged build: name: Upload Kavita.Common for Version Bump - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') steps: - name: Checkout Repo @@ -43,7 +43,7 @@ jobs: name: Build Stable and Nightly Docker if Release needs: [ build ] if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: packages: write contents: read @@ -106,7 +106,7 @@ jobs: - name: Compile dotnet app uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Install Swashbuckle CLI run: dotnet tool install -g Swashbuckle.AspNetCore.Cli @@ -114,6 +114,7 @@ jobs: - name: Login to Docker Hub uses: docker/login-action@v3 + if: ${{ github.repository_owner == 'Kareadita' }} with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} @@ -132,23 +133,47 @@ jobs: id: buildx uses: docker/setup-buildx-action@v3 + - name: Extract metadata (tags, labels) for Docker + id: docker_meta_stable + uses: docker/metadata-action@v5 + with: + tags: | + type=raw,value=latest + type=raw,value=${{ steps.parse-version.outputs.VERSION }} + images: | + name=jvmilazz0/kavita,enable=${{ github.repository_owner == 'Kareadita' }} + name=ghcr.io/${{ github.repository }} + - name: Build and push stable id: docker_build_stable - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true - tags: jvmilazz0/kavita:latest, jvmilazz0/kavita:${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:latest, ghcr.io/kareadita/kavita:${{ steps.parse-version.outputs.VERSION }} + tags: ${{ steps.docker_meta_stable.outputs.tags }} + labels: ${{ steps.docker_meta_stable.outputs.labels }} + + - name: Extract metadata (tags, labels) for Docker + id: docker_meta_nightly + uses: docker/metadata-action@v5 + with: + tags: | + type=raw,value=nightly + type=raw,value=nightly-${{ steps.parse-version.outputs.VERSION }} + images: | + name=jvmilazz0/kavita,enable=${{ github.repository_owner == 'Kareadita' }} + name=ghcr.io/${{ github.repository }} - name: Build and push nightly id: docker_build_nightly - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true - tags: jvmilazz0/kavita:nightly, jvmilazz0/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:nightly, ghcr.io/kareadita/kavita:nightly-${{ steps.parse-version.outputs.VERSION }} + tags: ${{ steps.docker_meta_nightly.outputs.tags }} + labels: ${{ steps.docker_meta_nightly.outputs.labels }} - name: Image digest run: echo ${{ steps.docker_build_stable.outputs.digest }} diff --git a/.gitignore b/.gitignore index 71a904556..1cffb441d 100644 --- a/.gitignore +++ b/.gitignore @@ -513,6 +513,7 @@ UI/Web/dist/ /API/config/stats/ /API/config/bookmarks/ /API/config/favicons/ +/API/config/cache-long/ /API/config/kavita.db /API/config/kavita.db-shm /API/config/kavita.db-wal diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index 2dcf08f32..ec9c1884f 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 Exe @@ -10,9 +10,9 @@ - - - + + + @@ -26,5 +26,10 @@ Always + + + PreserveNewest + + diff --git a/API.Benchmark/ArchiveServiceBenchmark.cs b/API.Benchmark/ArchiveServiceBenchmark.cs index 9ef8e237b..ccb44d517 100644 --- a/API.Benchmark/ArchiveServiceBenchmark.cs +++ b/API.Benchmark/ArchiveServiceBenchmark.cs @@ -32,7 +32,7 @@ public class ArchiveServiceBenchmark public ArchiveServiceBenchmark() { _directoryService = new DirectoryService(null, new FileSystem()); - _imageService = new ImageService(null, _directoryService, Substitute.For()); + _imageService = new ImageService(null, _directoryService); _archiveService = new ArchiveService(new NullLogger(), _directoryService, _imageService, Substitute.For()); } diff --git a/API.Benchmark/Data/AesopsFables.epub b/API.Benchmark/Data/AesopsFables.epub new file mode 100644 index 000000000..d2ab9a8b2 Binary files /dev/null and b/API.Benchmark/Data/AesopsFables.epub differ diff --git a/API.Benchmark/KoreaderHashBenchmark.cs b/API.Benchmark/KoreaderHashBenchmark.cs new file mode 100644 index 000000000..c0abfd2ad --- /dev/null +++ b/API.Benchmark/KoreaderHashBenchmark.cs @@ -0,0 +1,41 @@ +using API.Helpers.Builders; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Order; +using System; +using API.Entities.Enums; + +namespace API.Benchmark +{ + [StopOnFirstError] + [MemoryDiagnoser] + [RankColumn] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] + [SimpleJob(launchCount: 1, warmupCount: 5, invocationCount: 20)] + public class KoreaderHashBenchmark + { + private const string sourceEpub = "./Data/AesopsFables.epub"; + + [Benchmark(Baseline = true)] + public void TestBuildManga_baseline() + { + var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub) + .Build(); + if (file == null) + { + throw new Exception("Failed to build manga file"); + } + } + + [Benchmark] + public void TestBuildManga_withHash() + { + var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub) + .WithHash() + .Build(); + if (file == null) + { + throw new Exception("Failed to build manga file"); + } + } + } +} diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 6aefef830..a571a6e72 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -1,22 +1,22 @@ - net8.0 + net9.0 false - - - - - - - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -28,6 +28,7 @@ + @@ -35,4 +36,10 @@ + + + PreserveNewest + + + diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index a3464db9d..9c5f3e726 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -1,6 +1,5 @@ -using System.Collections.Generic; +using System; using System.Data.Common; -using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -10,7 +9,7 @@ using API.Helpers; using API.Helpers.Builders; using API.Services; using AutoMapper; -using Microsoft.AspNetCore.Identity; +using Hangfire; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -19,37 +18,34 @@ using NSubstitute; namespace API.Tests; -public abstract class AbstractDbTest +public abstract class AbstractDbTest : AbstractFsTest , IDisposable { - protected readonly DbConnection _connection; - protected readonly DataContext _context; - protected readonly IUnitOfWork _unitOfWork; - - - protected const string CacheDirectory = "C:/kavita/config/cache/"; - protected const string CoverImageDirectory = "C:/kavita/config/covers/"; - protected const string BackupDirectory = "C:/kavita/config/backups/"; - protected const string LogDirectory = "C:/kavita/config/logs/"; - protected const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; - protected const string SiteThemeDirectory = "C:/kavita/config/themes/"; - protected const string TempDirectory = "C:/kavita/config/temp/"; - protected const string DataDirectory = "C:/data/"; + protected readonly DataContext Context; + protected readonly IUnitOfWork UnitOfWork; + protected readonly IMapper Mapper; + private readonly DbConnection _connection; + private bool _disposed; protected AbstractDbTest() { - var contextOptions = new DbContextOptionsBuilder() + var contextOptions = new DbContextOptionsBuilder() .UseSqlite(CreateInMemoryDatabase()) + .EnableSensitiveDataLogging() .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; - _context = new DataContext(contextOptions); + Context = new DataContext(contextOptions); + + Context.Database.EnsureCreated(); // Ensure DB schema is created + Task.Run(SeedDb).GetAwaiter().GetResult(); var config = new MapperConfiguration(cfg => cfg.AddProfile()); - var mapper = config.CreateMapper(); + Mapper = config.CreateMapper(); - - _unitOfWork = new UnitOfWork(_context, mapper, null); + GlobalConfiguration.Configuration.UseInMemoryStorage(); + UnitOfWork = new UnitOfWork(Context, Mapper, null); } private static DbConnection CreateInMemoryDatabase() @@ -62,47 +58,79 @@ public abstract class AbstractDbTest private async Task SeedDb() { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); + try + { + await Context.Database.EnsureCreatedAsync(); + var filesystem = CreateFileSystem(); - await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + await Seed.SeedSettings(Context, new DirectoryService(Substitute.For>(), filesystem)); - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; + var setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); - setting.Value = BackupDirectory; + setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); - setting.Value = BookmarkDirectory; + setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); + setting.Value = BookmarkDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync(); - setting.Value = "10"; + setting = await Context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync(); + setting.Value = "10"; - _context.ServerSetting.Update(setting); + Context.ServerSetting.Update(setting); - _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) - .Build()); - return await _context.SaveChangesAsync() > 0; + + Context.Library.Add(new LibraryBuilder("Manga") + .WithAllowMetadataMatching(true) + .WithFolderPath(new FolderPathBuilder(DataDirectory).Build()) + .Build()); + + await Context.SaveChangesAsync(); + + await Seed.SeedMetadataSettings(Context); + + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[SeedDb] Error: {ex.Message}"); + return false; + } } protected abstract Task ResetDb(); - protected static MockFileSystem CreateFileSystem() + public void Dispose() { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(BookmarkDirectory); - fileSystem.AddDirectory(SiteThemeDirectory); - fileSystem.AddDirectory(LogDirectory); - fileSystem.AddDirectory(TempDirectory); - fileSystem.AddDirectory(DataDirectory); + Dispose(true); + GC.SuppressFinalize(this); + } - return fileSystem; + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + + if (disposing) + { + Context?.Dispose(); + _connection?.Dispose(); + } + + _disposed = true; + } + + /// + /// Add a role to an existing User. Commits. + /// + /// + /// + protected async Task AddUserWithRole(int userId, string roleName) + { + var role = new AppRole { Id = userId, Name = roleName, NormalizedName = roleName.ToUpper() }; + + await Context.Roles.AddAsync(role); + await Context.UserRoles.AddAsync(new AppUserRole { UserId = userId, RoleId = userId }); + + await Context.SaveChangesAsync(); } } diff --git a/API.Tests/AbstractFsTest.cs b/API.Tests/AbstractFsTest.cs new file mode 100644 index 000000000..965a7ad78 --- /dev/null +++ b/API.Tests/AbstractFsTest.cs @@ -0,0 +1,44 @@ + + +using System.IO; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; +using API.Services.Tasks.Scanner.Parser; + +namespace API.Tests; + +public abstract class AbstractFsTest +{ + + protected static readonly string Root = Parser.NormalizePath(Path.GetPathRoot(Directory.GetCurrentDirectory())); + protected static readonly string ConfigDirectory = Root + "kavita/config/"; + protected static readonly string CacheDirectory = ConfigDirectory + "cache/"; + protected static readonly string CacheLongDirectory = ConfigDirectory + "cache-long/"; + protected static readonly string CoverImageDirectory = ConfigDirectory + "covers/"; + protected static readonly string BackupDirectory = ConfigDirectory + "backups/"; + protected static readonly string LogDirectory = ConfigDirectory + "logs/"; + protected static readonly string BookmarkDirectory = ConfigDirectory + "bookmarks/"; + protected static readonly string SiteThemeDirectory = ConfigDirectory + "themes/"; + protected static readonly string TempDirectory = ConfigDirectory + "temp/"; + protected static readonly string ThemesDirectory = ConfigDirectory + "theme"; + protected static readonly string DataDirectory = Root + "data/"; + + protected static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory(Root + "kavita/"); + fileSystem.AddDirectory(Root + "kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CacheLongDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(BookmarkDirectory); + fileSystem.AddDirectory(SiteThemeDirectory); + fileSystem.AddDirectory(LogDirectory); + fileSystem.AddDirectory(TempDirectory); + fileSystem.AddDirectory(DataDirectory); + fileSystem.AddDirectory(ThemesDirectory); + + return fileSystem; + } +} diff --git a/API.Tests/Converters/CronConverterTests.cs b/API.Tests/Converters/CronConverterTests.cs index 4e214e8f1..5568c89d0 100644 --- a/API.Tests/Converters/CronConverterTests.cs +++ b/API.Tests/Converters/CronConverterTests.cs @@ -1,5 +1,4 @@ using API.Helpers.Converters; -using Hangfire; using Xunit; namespace API.Tests.Converters; diff --git a/API.Tests/Data/AesopsFables.epub b/API.Tests/Data/AesopsFables.epub new file mode 100644 index 000000000..d2ab9a8b2 Binary files /dev/null and b/API.Tests/Data/AesopsFables.epub differ diff --git a/API.Tests/Extensions/ChapterListExtensionsTests.cs b/API.Tests/Extensions/ChapterListExtensionsTests.cs index d27903ca9..f19a0cede 100644 --- a/API.Tests/Extensions/ChapterListExtensionsTests.cs +++ b/API.Tests/Extensions/ChapterListExtensionsTests.cs @@ -142,7 +142,7 @@ public class ChapterListExtensionsTests CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false), }; - Assert.Equal(chapterList.First(), chapterList.GetFirstChapterWithFiles()); + Assert.Equal(chapterList[0], chapterList.GetFirstChapterWithFiles()); } [Fact] @@ -150,13 +150,13 @@ public class ChapterListExtensionsTests { var chapterList = new List() { - CreateChapter("darker than black", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true), + CreateChapter("darker than black", Parser.DefaultChapter, CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true), CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false), }; - chapterList.First().Files = new List(); + chapterList[0].Files = new List(); - Assert.Equal(chapterList.Last(), chapterList.GetFirstChapterWithFiles()); + Assert.Equal(chapterList[^1], chapterList.GetFirstChapterWithFiles()); } @@ -181,7 +181,7 @@ public class ChapterListExtensionsTests CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) }; - chapterList[0].ReleaseDate = new DateTime(10, 1, 1); + chapterList[0].ReleaseDate = new DateTime(10, 1, 1, 0, 0, 0, DateTimeKind.Utc); chapterList[1].ReleaseDate = DateTime.MinValue; Assert.Equal(0, chapterList.MinimumReleaseYear()); @@ -196,8 +196,8 @@ public class ChapterListExtensionsTests CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) }; - chapterList[0].ReleaseDate = new DateTime(2002, 1, 1); - chapterList[1].ReleaseDate = new DateTime(2012, 2, 1); + chapterList[0].ReleaseDate = new DateTime(2002, 1, 1, 0, 0, 0, DateTimeKind.Utc); + chapterList[1].ReleaseDate = new DateTime(2012, 2, 1, 0, 0, 0, DateTimeKind.Utc); Assert.Equal(2002, chapterList.MinimumReleaseYear()); } diff --git a/API.Tests/Extensions/EncodeFormatExtensionsTests.cs b/API.Tests/Extensions/EncodeFormatExtensionsTests.cs new file mode 100644 index 000000000..a02de84aa --- /dev/null +++ b/API.Tests/Extensions/EncodeFormatExtensionsTests.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Entities.Enums; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class EncodeFormatExtensionsTests +{ + [Fact] + public void GetExtension_ShouldReturnCorrectExtensionForAllValues() + { + // Arrange + var expectedExtensions = new Dictionary + { + { EncodeFormat.PNG, ".png" }, + { EncodeFormat.WEBP, ".webp" }, + { EncodeFormat.AVIF, ".avif" } + }; + + // Act & Assert + foreach (var format in Enum.GetValues(typeof(EncodeFormat)).Cast()) + { + var extension = format.GetExtension(); + Assert.Equal(expectedExtensions[format], extension); + } + } + +} diff --git a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs index 325b19c5d..227dd2b32 100644 --- a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs +++ b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs @@ -7,7 +7,6 @@ using API.Extensions; using API.Helpers.Builders; using API.Services; using API.Services.Tasks.Scanner.Parser; -using API.Tests.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs index d902ae353..96d74b46d 100644 --- a/API.Tests/Extensions/QueryableExtensionsTests.cs +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -1,11 +1,9 @@ using System.Collections.Generic; using System.Linq; -using API.Data; using API.Data.Misc; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; +using API.Entities.Person; using API.Extensions.QueryExtensions; using API.Helpers.Builders; using Xunit; @@ -69,7 +67,7 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] - [InlineData(false, 1)] + [InlineData(false, 2)] public void RestrictAgainstAgeRestriction_Genre_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) { var items = new List() @@ -96,7 +94,7 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] - [InlineData(false, 1)] + [InlineData(false, 2)] public void RestrictAgainstAgeRestriction_Tag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) { var items = new List() @@ -123,14 +121,14 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] - [InlineData(false, 1)] - public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + [InlineData(false, 2)] + public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedPeopleCount) { // Arrange var items = new List { CreatePersonWithSeriesMetadata("Test1", AgeRating.Teen), - CreatePersonWithSeriesMetadata("Test2", AgeRating.Unknown, AgeRating.Teen), + CreatePersonWithSeriesMetadata("Test2", AgeRating.Unknown, AgeRating.Teen), // 2 series on this person, restrict will still allow access CreatePersonWithSeriesMetadata("Test3", AgeRating.X18Plus) }; @@ -144,7 +142,7 @@ public class QueryableExtensionsTests var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(ageRestriction); // Assert - Assert.Equal(expectedCount, filtered.Count()); + Assert.Equal(expectedPeopleCount, filtered.Count()); } private static Person CreatePersonWithSeriesMetadata(string name, params AgeRating[] ageRatings) diff --git a/API.Tests/Extensions/SeriesFilterTests.cs b/API.Tests/Extensions/SeriesFilterTests.cs index 2774ad78e..ba42be8a1 100644 --- a/API.Tests/Extensions/SeriesFilterTests.cs +++ b/API.Tests/Extensions/SeriesFilterTests.cs @@ -1,28 +1,1338 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using API.DTOs; using API.DTOs.Filtering.v2; +using API.DTOs.Progress; +using API.Entities; +using API.Entities.Enums; using API.Extensions.QueryExtensions.Filtering; +using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; +using API.SignalR; +using Kavita.Common; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace API.Tests.Extensions; public class SeriesFilterTests : AbstractDbTest { - - protected override Task ResetDb() + protected override async Task ResetDb() { - return Task.CompletedTask; + Context.Series.RemoveRange(Context.Series); + Context.AppUser.RemoveRange(Context.AppUser); + await Context.SaveChangesAsync(); } + #region HasProgress + + private async Task SetupHasProgress() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("None").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Partial").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Full").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + + // Create read progress on Partial and Full + var readerService = new ReaderService(UnitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); + + // Select Partial and set pages read to 5 on first chapter + var partialSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2); + var partialChapter = partialSeries.Volumes.First().Chapters.First(); + + Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = partialChapter.Id, + LibraryId = 1, + SeriesId = partialSeries.Id, + PageNum = 5, + VolumeId = partialChapter.VolumeId + }, user.Id)); + + // Select Full and set pages read to 10 on first chapter + var fullSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(3); + var fullChapter = fullSeries.Volumes.First().Chapters.First(); + + Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = fullChapter.Id, + LibraryId = 1, + SeriesId = fullSeries.Id, + PageNum = 10, + VolumeId = fullChapter.VolumeId + }, user.Id)); + + return user; + } + + [Fact] + public async Task HasProgress_LessThan50_ShouldReturnSingle() + { + var user = await SetupHasProgress(); + + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThan, 50, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("None", queryResult.First().Name); + } + + [Fact] + public async Task HasProgress_LessThanOrEqual50_ShouldReturnTwo() + { + var user = await SetupHasProgress(); + + // Query series with progress <= 50% + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 50, user.Id) + .ToListAsync(); + + Assert.Equal(2, queryResult.Count); + Assert.Contains(queryResult, s => s.Name == "None"); + Assert.Contains(queryResult, s => s.Name == "Partial"); + } + + [Fact] + public async Task HasProgress_GreaterThan50_ShouldReturnFull() + { + var user = await SetupHasProgress(); + + // Query series with progress > 50% + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.GreaterThan, 50, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("Full", queryResult.First().Name); + } + + [Fact] + public async Task HasProgress_Equal100_ShouldReturnFull() + { + var user = await SetupHasProgress(); + + // Query series with progress == 100% + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.Equal, 100, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("Full", queryResult.First().Name); + } + + [Fact] + public async Task HasProgress_LessThan100_ShouldReturnTwo() + { + var user = await SetupHasProgress(); + + // Query series with progress < 100% + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id) + .ToListAsync(); + + Assert.Equal(2, queryResult.Count); + Assert.Contains(queryResult, s => s.Name == "None"); + Assert.Contains(queryResult, s => s.Name == "Partial"); + } + + [Fact] + public async Task HasProgress_LessThanOrEqual100_ShouldReturnAll() + { + var user = await SetupHasProgress(); + + // Query series with progress <= 100% + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 100, user.Id) + .ToListAsync(); + + Assert.Equal(3, queryResult.Count); + Assert.Contains(queryResult, s => s.Name == "None"); + Assert.Contains(queryResult, s => s.Name == "Partial"); + Assert.Contains(queryResult, s => s.Name == "Full"); + } + + [Fact] + public async Task HasProgress_LessThan100_WithProgress99_99_ShouldReturnSeries() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("AlmostFull").WithPages(100) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(100).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + var readerService = new ReaderService(UnitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); + + // Set progress to 99.99% (99/100 pages read) + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var chapter = series.Volumes.First().Chapters.First(); + + Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = chapter.Id, + LibraryId = 1, + SeriesId = series.Id, + PageNum = 99, + VolumeId = chapter.VolumeId + }, user.Id)); + + // Query series with progress < 100% + var queryResult = await Context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("AlmostFull", queryResult.First().Name); + } + #endregion + #region HasLanguage - [Fact] - public async Task HasLanguage_Works() + private async Task SetupHasLanguage() { - var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Contains, new List() { }).ToListAsync(); + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("English").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithLanguage("en").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("French").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithLanguage("fr").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Spanish").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithLanguage("es").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasLanguage_Equal_Works() + { + await SetupHasLanguage(); + + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Equal, ["en"]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("en", foundSeries[0].Metadata.Language); + } + + [Fact] + public async Task HasLanguage_NotEqual_Works() + { + await SetupHasLanguage(); + + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.NotEqual, ["en"]).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.DoesNotContain(foundSeries, s => s.Metadata.Language == "en"); + } + + [Fact] + public async Task HasLanguage_Contains_Works() + { + await SetupHasLanguage(); + + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Contains, ["en", "fr"]).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Metadata.Language == "en"); + Assert.Contains(foundSeries, s => s.Metadata.Language == "fr"); + } + + [Fact] + public async Task HasLanguage_NotContains_Works() + { + await SetupHasLanguage(); + + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.NotContains, ["en", "fr"]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("es", foundSeries[0].Metadata.Language); + } + + [Fact] + public async Task HasLanguage_MustContains_Works() + { + await SetupHasLanguage(); + + // Since "MustContains" matches all the provided languages, no series should match in this case. + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.MustContains, ["en", "fr"]).ToListAsync(); + Assert.Empty(foundSeries); + + // Single language should work. + foundSeries = await Context.Series.HasLanguage(true, FilterComparison.MustContains, ["en"]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("en", foundSeries[0].Metadata.Language); + } + + [Fact] + public async Task HasLanguage_Matches_Works() + { + await SetupHasLanguage(); + + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Matches, ["e"]).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains("en", foundSeries.Select(s => s.Metadata.Language)); + Assert.Contains("es", foundSeries.Select(s => s.Metadata.Language)); + } + + [Fact] + public async Task HasLanguage_DisabledCondition_ReturnsAll() + { + await SetupHasLanguage(); + + var foundSeries = await Context.Series.HasLanguage(false, FilterComparison.Equal, ["en"]).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasLanguage_EmptyLanguageList_ReturnsAll() + { + await SetupHasLanguage(); + + var foundSeries = await Context.Series.HasLanguage(true, FilterComparison.Equal, new List()).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasLanguage_UnsupportedComparison_ThrowsException() + { + await SetupHasLanguage(); + + await Assert.ThrowsAsync(async () => + { + await Context.Series.HasLanguage(true, FilterComparison.GreaterThan, ["en"]).ToListAsync(); + }); } + #endregion + + #region HasAverageRating + + private async Task SetupHasAverageRating() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("None").WithPages(10) + .WithExternalMetadata(new ExternalSeriesMetadataBuilder().WithAverageExternalRating(-1).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Partial").WithPages(10) + .WithExternalMetadata(new ExternalSeriesMetadataBuilder().WithAverageExternalRating(50).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Full").WithPages(10) + .WithExternalMetadata(new ExternalSeriesMetadataBuilder().WithAverageExternalRating(100).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasAverageRating_Equal_Works() + { + await SetupHasAverageRating(); + + var series = await Context.Series.HasAverageRating(true, FilterComparison.Equal, 100).ToListAsync(); + Assert.Single(series); + Assert.Equal("Full", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_GreaterThan_Works() + { + await SetupHasAverageRating(); + + var series = await Context.Series.HasAverageRating(true, FilterComparison.GreaterThan, 50).ToListAsync(); + Assert.Single(series); + Assert.Equal("Full", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_GreaterThanEqual_Works() + { + await SetupHasAverageRating(); + + var series = await Context.Series.HasAverageRating(true, FilterComparison.GreaterThanEqual, 50).ToListAsync(); + Assert.Equal(2, series.Count); + Assert.Contains(series, s => s.Name == "Partial"); + Assert.Contains(series, s => s.Name == "Full"); + } + + [Fact] + public async Task HasAverageRating_LessThan_Works() + { + await SetupHasAverageRating(); + + var series = await Context.Series.HasAverageRating(true, FilterComparison.LessThan, 50).ToListAsync(); + Assert.Single(series); + Assert.Equal("None", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_LessThanEqual_Works() + { + await SetupHasAverageRating(); + + var series = await Context.Series.HasAverageRating(true, FilterComparison.LessThanEqual, 50).ToListAsync(); + Assert.Equal(2, series.Count); + Assert.Contains(series, s => s.Name == "None"); + Assert.Contains(series, s => s.Name == "Partial"); + } + + [Fact] + public async Task HasAverageRating_NotEqual_Works() + { + await SetupHasAverageRating(); + + var series = await Context.Series.HasAverageRating(true, FilterComparison.NotEqual, 100).ToListAsync(); + Assert.Equal(2, series.Count); + Assert.DoesNotContain(series, s => s.Name == "Full"); + } + + [Fact] + public async Task HasAverageRating_ConditionFalse_ReturnsAll() + { + await SetupHasAverageRating(); + + var series = await Context.Series.HasAverageRating(false, FilterComparison.Equal, 100).ToListAsync(); + Assert.Equal(3, series.Count); + } + + [Fact] + public async Task HasAverageRating_NotSet_IsHandled() + { + await SetupHasAverageRating(); + + var series = await Context.Series.HasAverageRating(true, FilterComparison.Equal, -1).ToListAsync(); + Assert.Single(series); + Assert.Equal("None", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_ThrowsForInvalidComparison() + { + await SetupHasAverageRating(); + + await Assert.ThrowsAsync(async () => + { + await Context.Series.HasAverageRating(true, FilterComparison.Contains, 50).ToListAsync(); + }); + } + + [Fact] + public async Task HasAverageRating_ThrowsForOutOfRangeComparison() + { + await SetupHasAverageRating(); + + await Assert.ThrowsAsync(async () => + { + await Context.Series.HasAverageRating(true, (FilterComparison)999, 50).ToListAsync(); + }); + } + + #endregion + + # region HasPublicationStatus + + private async Task SetupHasPublicationStatus() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Cancelled").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithPublicationStatus(PublicationStatus.Cancelled).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("OnGoing").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithPublicationStatus(PublicationStatus.OnGoing).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Completed").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithPublicationStatus(PublicationStatus.Completed).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasPublicationStatus_Equal_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List { PublicationStatus.Cancelled }).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("Cancelled", foundSeries[0].Name); + } + + [Fact] + public async Task HasPublicationStatus_Contains_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.Contains, new List { PublicationStatus.Cancelled, PublicationStatus.Completed }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Cancelled"); + Assert.Contains(foundSeries, s => s.Name == "Completed"); + } + + [Fact] + public async Task HasPublicationStatus_NotContains_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.NotContains, new List { PublicationStatus.Cancelled }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "OnGoing"); + Assert.Contains(foundSeries, s => s.Name == "Completed"); + } + + [Fact] + public async Task HasPublicationStatus_NotEqual_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.NotEqual, new List { PublicationStatus.OnGoing }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Cancelled"); + Assert.Contains(foundSeries, s => s.Name == "Completed"); + } + + [Fact] + public async Task HasPublicationStatus_ConditionFalse_ReturnsAll() + { + await SetupHasPublicationStatus(); + + var foundSeries = await Context.Series.HasPublicationStatus(false, FilterComparison.Equal, new List { PublicationStatus.Cancelled }).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasPublicationStatus_EmptyPubStatuses_ReturnsAll() + { + await SetupHasPublicationStatus(); + + var foundSeries = await Context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List()).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasPublicationStatus_ThrowsForInvalidComparison() + { + await SetupHasPublicationStatus(); + + await Assert.ThrowsAsync(async () => + { + await Context.Series.HasPublicationStatus(true, FilterComparison.BeginsWith, new List { PublicationStatus.Cancelled }).ToListAsync(); + }); + } + + [Fact] + public async Task HasPublicationStatus_ThrowsForOutOfRangeComparison() + { + await SetupHasPublicationStatus(); + + await Assert.ThrowsAsync(async () => + { + await Context.Series.HasPublicationStatus(true, (FilterComparison)999, new List { PublicationStatus.Cancelled }).ToListAsync(); + }); + } + #endregion + + #region HasAgeRating + private async Task SetupHasAgeRating() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Unknown").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("G").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.G).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Mature").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Mature).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasAgeRating_Equal_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.Equal, [AgeRating.G]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("G", foundSeries[0].Name); + } + + [Fact] + public async Task HasAgeRating_Contains_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.Contains, new List { AgeRating.G, AgeRating.Mature }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_NotContains_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.NotContains, new List { AgeRating.Unknown }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_NotEqual_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.NotEqual, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Unknown"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_GreaterThan_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.GreaterThan, new List { AgeRating.Unknown }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_GreaterThanEqual_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.GreaterThanEqual, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_LessThan_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.LessThan, new List { AgeRating.Mature }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Unknown"); + Assert.Contains(foundSeries, s => s.Name == "G"); + } + + [Fact] + public async Task HasAgeRating_LessThanEqual_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.LessThanEqual, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Unknown"); + Assert.Contains(foundSeries, s => s.Name == "G"); + } + + [Fact] + public async Task HasAgeRating_ConditionFalse_ReturnsAll() + { + await SetupHasAgeRating(); + + var foundSeries = await Context.Series.HasAgeRating(false, FilterComparison.Equal, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasAgeRating_EmptyRatings_ReturnsAll() + { + await SetupHasAgeRating(); + + var foundSeries = await Context.Series.HasAgeRating(true, FilterComparison.Equal, new List()).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasAgeRating_ThrowsForInvalidComparison() + { + await SetupHasAgeRating(); + + await Assert.ThrowsAsync(async () => + { + await Context.Series.HasAgeRating(true, FilterComparison.BeginsWith, new List { AgeRating.G }).ToListAsync(); + }); + } + + [Fact] + public async Task HasAgeRating_ThrowsForOutOfRangeComparison() + { + await SetupHasAgeRating(); + + await Assert.ThrowsAsync(async () => + { + await Context.Series.HasAgeRating(true, (FilterComparison)999, new List { AgeRating.G }).ToListAsync(); + }); + } + + #endregion + + #region HasReleaseYear + + private async Task SetupHasReleaseYear() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("2000").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2000).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("2020").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2020).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("2025").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2025).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasReleaseYear_Equal_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.Equal, 2020).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("2020", foundSeries[0].Name); + } + + [Fact] + public async Task HasReleaseYear_GreaterThan_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.GreaterThan, 2000).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "2020"); + Assert.Contains(foundSeries, s => s.Name == "2025"); + } + + [Fact] + public async Task HasReleaseYear_LessThan_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.LessThan, 2025).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "2000"); + Assert.Contains(foundSeries, s => s.Name == "2020"); + } + + [Fact] + public async Task HasReleaseYear_IsInLast_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.IsInLast, 5).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + } + + [Fact] + public async Task HasReleaseYear_IsNotInLast_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.IsNotInLast, 5).ToListAsync(); + Assert.Single(foundSeries); + Assert.Contains(foundSeries, s => s.Name == "2000"); + } + + [Fact] + public async Task HasReleaseYear_ConditionFalse_ReturnsAll() + { + await SetupHasReleaseYear(); + + var foundSeries = await Context.Series.HasReleaseYear(false, FilterComparison.Equal, 2020).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasReleaseYear_ReleaseYearNull_ReturnsAll() + { + await SetupHasReleaseYear(); + + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.Equal, null).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasReleaseYear_IsEmpty_Works() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("EmptyYear").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(0).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + var foundSeries = await Context.Series.HasReleaseYear(true, FilterComparison.IsEmpty, 0).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("EmptyYear", foundSeries[0].Name); + } + + + #endregion + + #region HasRating + + private async Task SetupHasRating() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("No Rating").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("0 Rating").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("4.5 Rating").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + var ratingService = new RatingService(UnitOfWork, Substitute.For(), Substitute.For>()); + + // Select 0 Rating + var zeroRating = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2); + Assert.NotNull(zeroRating); + + Assert.True(await ratingService.UpdateSeriesRating(user, new UpdateRatingDto() + { + SeriesId = zeroRating.Id, + UserRating = 0 + })); + + // Select 4.5 Rating + var partialRating = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(3); + + Assert.True(await ratingService.UpdateSeriesRating(user, new UpdateRatingDto() + { + SeriesId = partialRating.Id, + UserRating = 4.5f + })); + + return user; + } + + [Fact] + public async Task HasRating_Equal_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await Context.Series + .HasRating(true, FilterComparison.Equal, 4.5f, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("4.5 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_GreaterThan_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await Context.Series + .HasRating(true, FilterComparison.GreaterThan, 0, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("4.5 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_LessThan_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await Context.Series + .HasRating(true, FilterComparison.LessThan, 4.5f, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("0 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_IsEmpty_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await Context.Series + .HasRating(true, FilterComparison.IsEmpty, 0, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("No Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_GreaterThanEqual_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await Context.Series + .HasRating(true, FilterComparison.GreaterThanEqual, 4.5f, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("4.5 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_LessThanEqual_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await Context.Series + .HasRating(true, FilterComparison.LessThanEqual, 0, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("0 Rating", foundSeries[0].Name); + } + + #endregion + + #region HasAverageReadTime + + + + #endregion + + #region HasReadLast + + + + #endregion + + #region HasReadingDate + + + + #endregion + + #region HasTags + + + + #endregion + + #region HasPeople + + + + #endregion + + #region HasGenre + + + + #endregion + + #region HasFormat + + + + #endregion + + #region HasCollectionTags + + + + #endregion + + #region HasName + + private async Task SetupHasName() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Don't Toy With Me, Miss Nagatoro").WithLocalizedName("Ijiranaide, Nagatoro-san").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("My Dress-Up Darling").WithLocalizedName("Sono Bisque Doll wa Koi wo Suru").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasName_Equal_Works() + { + await SetupHasName(); + + var foundSeries = await Context.Series + .HasName(true, FilterComparison.Equal, "My Dress-Up Darling") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("My Dress-Up Darling", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_Equal_LocalizedName_Works() + { + await SetupHasName(); + + var foundSeries = await Context.Series + .HasName(true, FilterComparison.Equal, "Ijiranaide, Nagatoro-san") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_BeginsWith_Works() + { + await SetupHasName(); + + var foundSeries = await Context.Series + .HasName(true, FilterComparison.BeginsWith, "My Dress") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("My Dress-Up Darling", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_BeginsWith_LocalizedName_Works() + { + await SetupHasName(); + + var foundSeries = await Context.Series + .HasName(true, FilterComparison.BeginsWith, "Sono Bisque") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("My Dress-Up Darling", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_EndsWith_Works() + { + await SetupHasName(); + + var foundSeries = await Context.Series + .HasName(true, FilterComparison.EndsWith, "Nagatoro") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_Matches_Works() + { + await SetupHasName(); + + var foundSeries = await Context.Series + .HasName(true, FilterComparison.Matches, "Toy With Me") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_NotEqual_Works() + { + await SetupHasName(); + + var foundSeries = await Context.Series + .HasName(true, FilterComparison.NotEqual, "My Dress-Up Darling") + .ToListAsync(); + + Assert.Equal(2, foundSeries.Count); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + + #endregion + + #region HasSummary + + private async Task SetupHasSummary() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Hippos").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithSummary("I like hippos").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Apples").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithSummary("I like apples").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Ducks").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithSummary("I like ducks").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("No Summary").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + Context.Users.Add(user); + Context.Library.Add(library); + await Context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasSummary_Equal_Works() + { + await SetupHasSummary(); + + var foundSeries = await Context.Series + .HasSummary(true, FilterComparison.Equal, "I like hippos") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Hippos", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_BeginsWith_Works() + { + await SetupHasSummary(); + + var foundSeries = await Context.Series + .HasSummary(true, FilterComparison.BeginsWith, "I like h") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Hippos", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_EndsWith_Works() + { + await SetupHasSummary(); + + var foundSeries = await Context.Series + .HasSummary(true, FilterComparison.EndsWith, "apples") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Apples", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_Matches_Works() + { + await SetupHasSummary(); + + var foundSeries = await Context.Series + .HasSummary(true, FilterComparison.Matches, "like ducks") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Ducks", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_NotEqual_Works() + { + await SetupHasSummary(); + + var foundSeries = await Context.Series + .HasSummary(true, FilterComparison.NotEqual, "I like ducks") + .ToListAsync(); + + Assert.Equal(3, foundSeries.Count); + Assert.DoesNotContain(foundSeries, s => s.Name == "Ducks"); + } + + [Fact] + public async Task HasSummary_IsEmpty_Works() + { + await SetupHasSummary(); + + var foundSeries = await Context.Series + .HasSummary(true, FilterComparison.IsEmpty, string.Empty) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("No Summary", foundSeries[0].Name); + } + + #endregion + + + #region HasPath + + + + #endregion + + + #region HasFilePath + + + #endregion } diff --git a/API.Tests/Extensions/VersionExtensionTests.cs b/API.Tests/Extensions/VersionExtensionTests.cs new file mode 100644 index 000000000..e19fd7312 --- /dev/null +++ b/API.Tests/Extensions/VersionExtensionTests.cs @@ -0,0 +1,81 @@ +using System; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class VersionHelperTests +{ + [Fact] + public void CompareWithoutRevision_ShouldReturnTrue_WhenMajorMinorBuildMatch() + { + // Arrange + var v1 = new Version(1, 2, 3, 4); + var v2 = new Version(1, 2, 3, 5); + + // Act + var result = v1.CompareWithoutRevision(v2); + + // Assert + Assert.True(result); + } + + [Fact] + public void CompareWithoutRevision_ShouldHandleBuildlessVersions() + { + // Arrange + var v1 = new Version(1, 2); + var v2 = new Version(1, 2); + + // Act + var result = v1.CompareWithoutRevision(v2); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData(1, 2, 3, 1, 2, 4)] + [InlineData(1, 2, 3, 1, 2, 0)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenBuildDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } + + [Theory] + [InlineData(1, 2, 3, 1, 3, 3)] + [InlineData(1, 2, 3, 1, 0, 3)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenMinorDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } + + [Theory] + [InlineData(1, 2, 3, 2, 2, 3)] + [InlineData(1, 2, 3, 0, 2, 3)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenMajorDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } +} diff --git a/API.Tests/Extensions/VolumeListExtensionsTests.cs b/API.Tests/Extensions/VolumeListExtensionsTests.cs index b8b734c51..bbb8f215c 100644 --- a/API.Tests/Extensions/VolumeListExtensionsTests.cs +++ b/API.Tests/Extensions/VolumeListExtensionsTests.cs @@ -3,7 +3,6 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers.Builders; -using API.Tests.Helpers; using Xunit; namespace API.Tests.Extensions; diff --git a/API.Tests/Helpers/BookSortTitlePrefixHelperTests.cs b/API.Tests/Helpers/BookSortTitlePrefixHelperTests.cs new file mode 100644 index 000000000..e1f585806 --- /dev/null +++ b/API.Tests/Helpers/BookSortTitlePrefixHelperTests.cs @@ -0,0 +1,178 @@ +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class BookSortTitlePrefixHelperTests +{ + [Theory] + [InlineData("The Avengers", "Avengers")] + [InlineData("A Game of Thrones", "Game of Thrones")] + [InlineData("An American Tragedy", "American Tragedy")] + public void TestEnglishPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("El Quijote", "Quijote")] + [InlineData("La Casa de Papel", "Casa de Papel")] + [InlineData("Los Miserables", "Miserables")] + [InlineData("Las Vegas", "Vegas")] + [InlineData("Un Mundo Feliz", "Mundo Feliz")] + [InlineData("Una Historia", "Historia")] + public void TestSpanishPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("Le Petit Prince", "Petit Prince")] + [InlineData("La Belle et la Bête", "Belle et la Bête")] + [InlineData("Les Misérables", "Misérables")] + [InlineData("Un Amour de Swann", "Amour de Swann")] + [InlineData("Une Vie", "Vie")] + [InlineData("Des Souris et des Hommes", "Souris et des Hommes")] + public void TestFrenchPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("Der Herr der Ringe", "Herr der Ringe")] + [InlineData("Die Verwandlung", "Verwandlung")] + [InlineData("Das Kapital", "Kapital")] + [InlineData("Ein Sommernachtstraum", "Sommernachtstraum")] + [InlineData("Eine Geschichte", "Geschichte")] + public void TestGermanPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("Il Nome della Rosa", "Nome della Rosa")] + [InlineData("La Divina Commedia", "Divina Commedia")] + [InlineData("Lo Hobbit", "Hobbit")] + [InlineData("Gli Ultimi", "Ultimi")] + [InlineData("Le Città Invisibili", "Città Invisibili")] + [InlineData("Un Giorno", "Giorno")] + [InlineData("Una Notte", "Notte")] + public void TestItalianPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("O Alquimista", "Alquimista")] + [InlineData("A Moreninha", "Moreninha")] + [InlineData("Os Lusíadas", "Lusíadas")] + [InlineData("As Meninas", "Meninas")] + [InlineData("Um Defeito de Cor", "Defeito de Cor")] + [InlineData("Uma História", "História")] + public void TestPortuguesePrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("", "")] // Empty string returns empty + [InlineData("Book", "Book")] // Single word, no change + [InlineData("Avengers", "Avengers")] // No prefix, no change + public void TestNoPrefixCases(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("The", "The")] // Just a prefix word alone + [InlineData("A", "A")] // Just single letter prefix alone + [InlineData("Le", "Le")] // French prefix alone + public void TestPrefixWordAlone(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("THE AVENGERS", "AVENGERS")] // All caps + [InlineData("the avengers", "avengers")] // All lowercase + [InlineData("The AVENGERS", "AVENGERS")] // Mixed case + [InlineData("tHe AvEnGeRs", "AvEnGeRs")] // Random case + public void TestCaseInsensitivity(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("Then Came You", "Then Came You")] // "The" + "n" = not a prefix + [InlineData("And Then There Were None", "And Then There Were None")] // "An" + "d" = not a prefix + [InlineData("Elsewhere", "Elsewhere")] // "El" + "sewhere" = not a prefix (no space) + [InlineData("Lesson Plans", "Lesson Plans")] // "Les" + "son" = not a prefix (no space) + [InlineData("Theory of Everything", "Theory of Everything")] // "The" + "ory" = not a prefix + public void TestFalsePositivePrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("The ", "The ")] // Prefix with only space after - returns original + [InlineData("La ", "La ")] // Same for other languages + [InlineData("El ", "El ")] // Same for Spanish + public void TestPrefixWithOnlySpaceAfter(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("The Multiple Spaces", " Multiple Spaces")] // Doesn't trim extra spaces from remainder + [InlineData("Le Petit Prince", " Petit Prince")] // Leading space preserved in remainder + public void TestSpaceHandling(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("The The Matrix", "The Matrix")] // Removes first "The", leaves second + [InlineData("A A Clockwork Orange", "A Clockwork Orange")] // Removes first "A", leaves second + [InlineData("El El Cid", "El Cid")] // Spanish version + public void TestRepeatedPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("L'Étranger", "L'Étranger")] // French contraction - no space, no change + [InlineData("D'Artagnan", "D'Artagnan")] // Contraction - no space, no change + [InlineData("The-Matrix", "The-Matrix")] // Hyphen instead of space - no change + [InlineData("The.Avengers", "The.Avengers")] // Period instead of space - no change + public void TestNonSpaceSeparators(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("三国演义", "三国演义")] // Chinese - no processing due to CJK detection + [InlineData("한국어", "한국어")] // Korean - not in CJK range, would be processed normally + public void TestCjkLanguages(string inputString, string expected) + { + // NOTE: These don't do anything, I am waiting for user input on if these are needed + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("नमस्ते दुनिया", "नमस्ते दुनिया")] // Hindi - not CJK, processed normally + [InlineData("مرحبا بالعالم", "مرحبا بالعالم")] // Arabic - not CJK, processed normally + [InlineData("שלום עולם", "שלום עולם")] // Hebrew - not CJK, processed normally + public void TestNonLatinNonCjkScripts(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } + + [Theory] + [InlineData("в мире", "мире")] // Russian "в" (in) - should be removed + [InlineData("на столе", "столе")] // Russian "на" (on) - should be removed + [InlineData("с друзьями", "друзьями")] // Russian "с" (with) - should be removed + public void TestRussianPrefixes(string inputString, string expected) + { + Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString)); + } +} diff --git a/API.Tests/Helpers/CacheHelperTests.cs b/API.Tests/Helpers/CacheHelperTests.cs index 82f496a7b..3962ba2df 100644 --- a/API.Tests/Helpers/CacheHelperTests.cs +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.IO.Abstractions.TestingHelpers; -using API.Entities; using API.Entities.Enums; using API.Helpers; using API.Helpers.Builders; @@ -11,9 +10,9 @@ using Xunit; namespace API.Tests.Helpers; -public class CacheHelperTests +public class CacheHelperTests: AbstractFsTest { - private const string TestCoverImageDirectory = @"c:\"; + private static readonly string TestCoverImageDirectory = Root; private const string TestCoverImageFile = "thumbnail.jpg"; private readonly string _testCoverPath = Path.Join(TestCoverImageDirectory, TestCoverImageFile); private const string TestCoverArchive = @"file in folder.zip"; @@ -37,24 +36,29 @@ public class CacheHelperTests [Theory] [InlineData("", false)] - [InlineData("C:/", false)] [InlineData(null, false)] public void CoverImageExists_DoesFileExist(string coverImage, bool exists) { Assert.Equal(exists, _cacheHelper.CoverImageExists(coverImage)); } + [Fact] + public void CoverImageExists_DoesFileExistRoot() + { + Assert.False(_cacheHelper.CoverImageExists(Root)); + } + [Fact] public void CoverImageExists_FileExists() { - Assert.True(_cacheHelper.CoverImageExists(TestCoverArchive)); + Assert.True(_cacheHelper.CoverImageExists(Path.Join(TestCoverImageDirectory, TestCoverArchive))); } [Fact] public void ShouldUpdateCoverImage_OnFirstRun() { - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.True(_cacheHelper.ShouldUpdateCoverImage(null, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -65,7 +69,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -76,7 +80,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked_2() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now, @@ -87,7 +91,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -98,7 +102,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked_Modified() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -122,7 +126,7 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var created = DateTime.Now.Subtract(TimeSpan.FromHours(1)); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now.Subtract(TimeSpan.FromMinutes(1))) .Build(); @@ -133,9 +137,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceCreated() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime =now, }; var fileSystem = new MockFileSystem(new Dictionary { @@ -147,12 +152,12 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var chapter = new ChapterBuilder("1") - .WithLastModified(filesystemFile.LastWriteTime.DateTime) - .WithCreated(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) + .WithCreated(now.DateTime) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.True(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); } @@ -160,9 +165,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime = now, }; var fileSystem = new MockFileSystem(new Dictionary { @@ -174,12 +180,12 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var chapter = new ChapterBuilder("1") - .WithLastModified(filesystemFile.LastWriteTime.DateTime) - .WithCreated(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) + .WithCreated(now.DateTime) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.True(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); @@ -188,9 +194,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified_ForceUpdate() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime = now.DateTime, }; var fileSystem = new MockFileSystem(new Dictionary { @@ -202,12 +209,12 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var chapter = new ChapterBuilder("1") - .WithLastModified(filesystemFile.LastWriteTime.DateTime) - .WithCreated(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) + .WithCreated(now.DateTime) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, true, file)); } @@ -215,10 +222,11 @@ public class CacheHelperTests [Fact] public void IsFileUnmodifiedSinceCreationOrLastScan_ModifiedSinceLastScan() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now, - CreationTime = DateTimeOffset.Now + LastWriteTime = now.DateTime, + CreationTime = now.DateTime }; var fileSystem = new MockFileSystem(new Dictionary { @@ -234,8 +242,8 @@ public class CacheHelperTests .WithCreated(DateTime.Now.Subtract(TimeSpan.FromMinutes(10))) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); } @@ -243,9 +251,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_ModifiedSinceLastScan_ButLastModifiedSame() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime =now.DateTime }; var fileSystem = new MockFileSystem(new Dictionary { @@ -262,7 +271,7 @@ public class CacheHelperTests .Build(); var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) .Build(); Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); diff --git a/API.Tests/Helpers/KoreaderHelperTests.cs b/API.Tests/Helpers/KoreaderHelperTests.cs new file mode 100644 index 000000000..66d287a5d --- /dev/null +++ b/API.Tests/Helpers/KoreaderHelperTests.cs @@ -0,0 +1,60 @@ +using API.DTOs.Koreader; +using API.DTOs.Progress; +using API.Helpers; +using System.Runtime.CompilerServices; +using Xunit; + +namespace API.Tests.Helpers; + + +public class KoreaderHelperTests +{ + + [Theory] + [InlineData("/body/DocFragment[11]/body/div/a", 10, null)] + [InlineData("/body/DocFragment[1]/body/div/p[40]", 0, 40)] + [InlineData("/body/DocFragment[8]/body/div/p[28]/text().264", 7, 28)] + public void GetEpubPositionDto(string koreaderPosition, int page, int? pNumber) + { + var expected = EmptyProgressDto(); + expected.BookScrollId = pNumber.HasValue ? $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[{pNumber}]" : null; + expected.PageNum = page; + var actual = EmptyProgressDto(); + + KoreaderHelper.UpdateProgressDto(actual, koreaderPosition); + Assert.Equal(expected.BookScrollId, actual.BookScrollId); + Assert.Equal(expected.PageNum, actual.PageNum); + } + + + [Theory] + [InlineData("//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[20]", 5, "/body/DocFragment[6]/body/div/p[20]")] + [InlineData(null, 10, "/body/DocFragment[11]/body/div/a")] + public void GetKoreaderPosition(string scrollId, int page, string koreaderPosition) + { + var given = EmptyProgressDto(); + given.BookScrollId = scrollId; + given.PageNum = page; + + Assert.Equal(koreaderPosition, KoreaderHelper.GetKoreaderPosition(given)); + } + + [Theory] + [InlineData("./Data/AesopsFables.epub", "8795ACA4BF264B57C1EEDF06A0CEE688")] + public void GetKoreaderHash(string filePath, string hash) + { + Assert.Equal(KoreaderHelper.HashContents(filePath), hash); + } + + private ProgressDto EmptyProgressDto() + { + return new ProgressDto + { + ChapterId = 0, + PageNum = 0, + VolumeId = 0, + SeriesId = 0, + LibraryId = 0 + }; + } +} diff --git a/API.Tests/Helpers/OrderableHelperTests.cs b/API.Tests/Helpers/OrderableHelperTests.cs index a6d741be1..15f9e6268 100644 --- a/API.Tests/Helpers/OrderableHelperTests.cs +++ b/API.Tests/Helpers/OrderableHelperTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using API.Entities; using API.Helpers; @@ -49,17 +50,14 @@ public class OrderableHelperTests [Fact] public void ReorderItems_InvalidPosition_NoChange() { - // Arrange var items = new List { new AppUserSideNavStream { Id = 1, Order = 0, Name = "A" }, new AppUserSideNavStream { Id = 2, Order = 1, Name = "A" }, }; - // Act OrderableHelper.ReorderItems(items, 2, 3); // Position 3 is out of range - // Assert Assert.Equal(1, items[0].Id); // Item 1 should remain at position 0 Assert.Equal(2, items[1].Id); // Item 2 should remain at position 1 } @@ -80,7 +78,6 @@ public class OrderableHelperTests [Fact] public void ReorderItems_DoubleMove() { - // Arrange var items = new List { new AppUserSideNavStream { Id = 1, Order = 0, Name = "0" }, @@ -94,7 +91,6 @@ public class OrderableHelperTests // Move 4 -> 1 OrderableHelper.ReorderItems(items, 5, 1); - // Assert Assert.Equal(1, items[0].Id); Assert.Equal(0, items[0].Order); Assert.Equal(5, items[1].Id); @@ -109,4 +105,98 @@ public class OrderableHelperTests Assert.Equal("034125", string.Join("", items.Select(s => s.Name))); } + + private static List CreateTestReadingListItems(int count = 4) + { + var items = new List(); + + for (var i = 0; i < count; i++) + { + items.Add(new ReadingListItem() { Id = i + 1, Order = count, ReadingListId = i + 1}); + } + + return items; + } + + [Fact] + public void ReorderItems_MoveItemToBeginning_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 3, 0); + + Assert.Equal(3, items[0].Id); + Assert.Equal(1, items[1].Id); + Assert.Equal(2, items[2].Id); + Assert.Equal(4, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToEnd_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 1, 3); + + Assert.Equal(2, items[0].Id); + Assert.Equal(3, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(1, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToMiddle_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 4, 2); + + Assert.Equal(1, items[0].Id); + Assert.Equal(2, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(3, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToOutOfBoundsPosition_MovesToEnd() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 2, 10); + + Assert.Equal(1, items[0].Id); + Assert.Equal(3, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(2, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_NegativePosition_ThrowsArgumentException() + { + var items = CreateTestReadingListItems(); + + Assert.Throws(() => + OrderableHelper.ReorderItems(items, 2, -1) + ); + } } diff --git a/API.Tests/Helpers/ParserInfoHelperTests.cs b/API.Tests/Helpers/ParserInfoHelperTests.cs index 70ce3aa69..0bb7efb9b 100644 --- a/API.Tests/Helpers/ParserInfoHelperTests.cs +++ b/API.Tests/Helpers/ParserInfoHelperTests.cs @@ -1,8 +1,5 @@ using System.Collections.Generic; -using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services.Tasks.Scanner; diff --git a/API.Tests/Helpers/PersonHelperTests.cs b/API.Tests/Helpers/PersonHelperTests.cs index a25af7a07..47dab48da 100644 --- a/API.Tests/Helpers/PersonHelperTests.cs +++ b/API.Tests/Helpers/PersonHelperTests.cs @@ -1,14 +1,9 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.Data; -using API.DTOs; -using API.Entities; using API.Entities.Enums; using API.Helpers; using API.Helpers.Builders; -using API.Services.Tasks.Scanner.Parser; using Xunit; namespace API.Tests.Helpers; @@ -17,127 +12,215 @@ public class PersonHelperTests : AbstractDbTest { protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); - await _context.SaveChangesAsync(); + Context.Series.RemoveRange(Context.Series.ToList()); + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); + await Context.SaveChangesAsync(); } - // - // // 1. Test adding new people and keeping existing ones - // [Fact] - // public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained() - // { - // var existingPerson = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // // Create an existing person and assign them to the series with a role - // var series = new SeriesBuilder("Test 1") - // .WithFormat(MangaFormat.Archive) - // .WithMetadata(new SeriesMetadataBuilder() - // .WithPerson(existingPerson, PersonRole.Editor) - // .Build()) - // .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with one existing and one new person - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo", "New Person" }, PersonRole.Editor, _unitOfWork); - // - // // Assert existing person retained and new person added - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.Contains(people, p => p.Name == "Joe Shmo"); - // Assert.Contains(people, p => p.Name == "New Person"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.Contains("New Person", chapterPeople); - // } - // - // // 2. Test removing a person no longer in the list - // [Fact] - // public async Task UpdateChapterPeopleAsync_RemovePeople() - // { - // var existingPerson1 = new PersonBuilder("Joe Shmo").Build(); - // var existingPerson2 = new PersonBuilder("Jane Doe").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(existingPerson1, PersonRole.Editor) - // .WithPerson(existingPerson2, PersonRole.Editor) - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with only one person - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.DoesNotContain(people, p => p.Name == "Jane Doe"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.DoesNotContain("Jane Doe", chapterPeople); - // } - // - // // 3. Test no changes when the list of people is the same - // [Fact] - // public async Task UpdateChapterPeopleAsync_NoChanges() - // { - // var existingPerson = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(existingPerson, PersonRole.Editor) - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with the same list - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.Contains(people, p => p.Name == "Joe Shmo"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.Single(chapter.People); // No duplicate entries - // } - // - // // 4. Test multiple roles for a person - // [Fact] - // public async Task UpdateChapterPeopleAsync_MultipleRoles() - // { - // var person = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(person, PersonRole.Writer) // Assign person as Writer - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Add same person as Editor - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // // Ensure that the same person is assigned with two roles - // var chapterPeople = chapter.People.Where(cp => cp.Person.Name == "Joe Shmo").ToList(); - // Assert.Equal(2, chapterPeople.Count); // One for each role - // Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer); - // Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor); - // } + + // 1. Test adding new people and keeping existing ones + [Fact] + public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").Build(); + + // Create an existing person and assign them to the series with a role + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(existingPerson, PersonRole.Editor) + .Build()) + .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with one existing and one new person + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo", "New Person" }, PersonRole.Editor, UnitOfWork); + + // Assert existing person retained and new person added + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Contains(people, p => p.Name == "Joe Shmo"); + Assert.Contains(people, p => p.Name == "New Person"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.Contains("New Person", chapterPeople); + } + + // 2. Test removing a person no longer in the list + [Fact] + public async Task UpdateChapterPeopleAsync_RemovePeople() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson1 = new PersonBuilder("Joe Shmo").Build(); + var existingPerson2 = new PersonBuilder("Jane Doe").Build(); + var chapter = new ChapterBuilder("1") + .WithPerson(existingPerson1, PersonRole.Editor) + .WithPerson(existingPerson2, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with only one person + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + // PersonHelper does not remove the Person from the global DbSet itself + await UnitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); + + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.DoesNotContain(people, p => p.Name == "Jane Doe"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.DoesNotContain("Jane Doe", chapterPeople); + } + + // 3. Test no changes when the list of people is the same + [Fact] + public async Task UpdateChapterPeopleAsync_NoChanges() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").WithPerson(existingPerson, PersonRole.Editor).Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with the same list + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Contains(people, p => p.Name == "Joe Shmo"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.Single(chapter.People); // No duplicate entries + } + + // 4. Test multiple roles for a person + [Fact] + public async Task UpdateChapterPeopleAsync_MultipleRoles() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var person = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").WithPerson(person, PersonRole.Writer).Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Add same person as Editor + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + // Ensure that the same person is assigned with two roles + var chapterPeople = chapter + .People + .Where(cp => + cp.Person.Name == "Joe Shmo") + .ToList(); + Assert.Equal(2, chapterPeople.Count); // One for each role + Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer); + Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor); + } + + [Fact] + public async Task UpdateChapterPeopleAsync_MatchOnAlias_NoChanges() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var person = new PersonBuilder("Joe Doe") + .WithAlias("Jonny Doe") + .Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Add on Name + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Doe" }, PersonRole.Editor, UnitOfWork); + await UnitOfWork.CommitAsync(); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + // Add on alias + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Jonny Doe" }, PersonRole.Editor, UnitOfWork); + await UnitOfWork.CommitAsync(); + + allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + } + + // TODO: Unit tests for series } diff --git a/API.Tests/Helpers/RandfHelper.cs b/API.Tests/Helpers/RandfHelper.cs new file mode 100644 index 000000000..d8c007df7 --- /dev/null +++ b/API.Tests/Helpers/RandfHelper.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace API.Tests.Helpers; + +public class RandfHelper +{ + private static readonly Random Random = new (); + + /// + /// Returns true if all simple fields are equal + /// + /// + /// + /// fields to ignore, note that the names are very weird sometimes + /// + /// + /// + public static bool AreSimpleFieldsEqual(object obj1, object obj2, IList ignoreFields) + { + if (obj1 == null || obj2 == null) + throw new ArgumentNullException("Neither object can be null."); + + Type type1 = obj1.GetType(); + Type type2 = obj2.GetType(); + + if (type1 != type2) + throw new ArgumentException("Objects must be of the same type."); + + FieldInfo[] fields = type1.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic); + + foreach (var field in fields) + { + if (field.IsInitOnly) continue; + if (ignoreFields.Contains(field.Name)) continue; + + Type fieldType = field.FieldType; + + if (IsRelevantType(fieldType)) + { + object value1 = field.GetValue(obj1); + object value2 = field.GetValue(obj2); + + if (!Equals(value1, value2)) + { + throw new ArgumentException("Fields must be of the same type: " + field.Name + " was " + value1 + " and " + value2); + } + } + } + + return true; + } + + private static bool IsRelevantType(Type type) + { + return type.IsPrimitive + || type == typeof(string) + || type.IsEnum; + } + + /// + /// Sets all simple fields of the given object to a random value + /// + /// + /// Simple is, primitive, string, or enum + /// + public static void SetRandomValues(object obj) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + + Type type = obj.GetType(); + FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + foreach (var field in fields) + { + if (field.IsInitOnly) continue; // Skip readonly fields + + object value = GenerateRandomValue(field.FieldType); + if (value != null) + { + field.SetValue(obj, value); + } + } + } + + private static object GenerateRandomValue(Type type) + { + if (type == typeof(int)) + return Random.Next(); + if (type == typeof(float)) + return (float)Random.NextDouble() * 100; + if (type == typeof(double)) + return Random.NextDouble() * 100; + if (type == typeof(bool)) + return Random.Next(2) == 1; + if (type == typeof(char)) + return (char)Random.Next('A', 'Z' + 1); + if (type == typeof(byte)) + return (byte)Random.Next(0, 256); + if (type == typeof(short)) + return (short)Random.Next(short.MinValue, short.MaxValue); + if (type == typeof(long)) + return (long)(Random.NextDouble() * long.MaxValue); + if (type == typeof(string)) + return GenerateRandomString(10); + if (type.IsEnum) + { + var values = Enum.GetValues(type); + return values.GetValue(Random.Next(values.Length)); + } + + // Unsupported type + return null; + } + + private static string GenerateRandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[Random.Next(s.Length)]).ToArray()); + } +} diff --git a/API.Tests/Helpers/RateLimiterTests.cs b/API.Tests/Helpers/RateLimiterTests.cs index c05ce4e6d..e9b0030b9 100644 --- a/API.Tests/Helpers/RateLimiterTests.cs +++ b/API.Tests/Helpers/RateLimiterTests.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using API.Helpers; using Xunit; @@ -33,7 +34,7 @@ public class RateLimiterTests } [Fact] - public void AcquireTokens_Refill() + public async Task AcquireTokens_Refill() { // Arrange var limiter = new RateLimiter(2, TimeSpan.FromSeconds(1)); @@ -43,14 +44,14 @@ public class RateLimiterTests limiter.TryAcquire("test_key"); // Wait for refill - System.Threading.Thread.Sleep(1100); + await Task.Delay(1100); // Assert Assert.True(limiter.TryAcquire("test_key")); } [Fact] - public void AcquireTokens_Refill_WithOff() + public async Task AcquireTokens_Refill_WithOff() { // Arrange var limiter = new RateLimiter(2, TimeSpan.FromSeconds(10), false); @@ -60,7 +61,7 @@ public class RateLimiterTests limiter.TryAcquire("test_key"); // Wait for refill - System.Threading.Thread.Sleep(2100); + await Task.Delay(2100); // Assert Assert.False(limiter.TryAcquire("test_key")); diff --git a/API.Tests/Helpers/ReviewHelperTests.cs b/API.Tests/Helpers/ReviewHelperTests.cs new file mode 100644 index 000000000..b221c3c70 --- /dev/null +++ b/API.Tests/Helpers/ReviewHelperTests.cs @@ -0,0 +1,258 @@ +using API.Helpers; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using API.DTOs.SeriesDetail; + +namespace API.Tests.Helpers; + +public class ReviewHelperTests +{ + #region SelectSpectrumOfReviews Tests + + [Fact] + public void SelectSpectrumOfReviews_WhenLessThan10Reviews_ReturnsAllReviews() + { + // Arrange + var reviews = CreateReviewList(8); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(8, result.Count); + Assert.Equal(reviews, result.OrderByDescending(r => r.Score)); + } + + [Fact] + public void SelectSpectrumOfReviews_WhenMoreThan10Reviews_Returns10Reviews() + { + // Arrange + var reviews = CreateReviewList(20); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + Assert.Equal(reviews[0], result.First()); + Assert.Equal(reviews[19], result.Last()); + } + + [Fact] + public void SelectSpectrumOfReviews_WithExactly10Reviews_ReturnsAllReviews() + { + // Arrange + var reviews = CreateReviewList(10); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + } + + [Fact] + public void SelectSpectrumOfReviews_WithLargeNumberOfReviews_ReturnsCorrectSpectrum() + { + // Arrange + var reviews = CreateReviewList(100); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + Assert.Contains(reviews[0], result); + Assert.Contains(reviews[1], result); + Assert.Contains(reviews[98], result); + Assert.Contains(reviews[99], result); + } + + [Fact] + public void SelectSpectrumOfReviews_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var reviews = new List(); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void SelectSpectrumOfReviews_ResultsOrderedByScoreDescending() + { + // Arrange + var reviews = new List + { + new UserReviewDto { Tagline = "1", Score = 3 }, + new UserReviewDto { Tagline = "2", Score = 5 }, + new UserReviewDto { Tagline = "3", Score = 1 }, + new UserReviewDto { Tagline = "4", Score = 4 }, + new UserReviewDto { Tagline = "5", Score = 2 } + }; + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(5, result.Count); + Assert.Equal(5, result[0].Score); + Assert.Equal(4, result[1].Score); + Assert.Equal(3, result[2].Score); + Assert.Equal(2, result[3].Score); + Assert.Equal(1, result[4].Score); + } + + #endregion + + #region GetCharacters Tests + + [Fact] + public void GetCharacters_WithNullBody_ReturnsNull() + { + // Arrange + string body = null; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetCharacters_WithEmptyBody_ReturnsEmptyString() + { + // Arrange + var body = string.Empty; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCharacters_WithNoTextNodes_ReturnsEmptyString() + { + // Arrange + const string body = "
"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCharacters_WithLessCharactersThanLimit_ReturnsFullText() + { + // Arrange + var body = "

This is a short review.

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("This is a short review.…", result); + } + + [Fact] + public void GetCharacters_WithMoreCharactersThanLimit_TruncatesText() + { + // Arrange + var body = "

" + new string('a', 200) + "

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(new string('a', 175) + "…", result); + Assert.Equal(176, result.Length); // 175 characters + ellipsis + } + + [Fact] + public void GetCharacters_IgnoresScriptTags() + { + // Arrange + const string body = "

Visible text

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("Visible text…", result); + Assert.DoesNotContain("hidden", result); + } + + [Fact] + public void GetCharacters_RemovesMarkdownSymbols() + { + // Arrange + const string body = "

This is **bold** and _italic_ text with [link](url).

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("This is bold and italic text with link.…", result); + } + + [Fact] + public void GetCharacters_HandlesComplexMarkdownAndHtml() + { + // Arrange + const string body = """ + +
+

# Header

+

This is ~~strikethrough~~ and __underlined__ text

+

~~~code block~~~

+

+++highlighted+++

+

img123(image.jpg)

+
+ """; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.DoesNotContain("~~", result); + Assert.DoesNotContain("__", result); + Assert.DoesNotContain("~~~", result); + Assert.DoesNotContain("+++", result); + Assert.DoesNotContain("img123(", result); + Assert.Contains("Header", result); + Assert.Contains("strikethrough", result); + Assert.Contains("underlined", result); + Assert.Contains("code block", result); + Assert.Contains("highlighted", result); + } + + #endregion + + #region Helper Methods + + private static List CreateReviewList(int count) + { + var reviews = new List(); + for (var i = 0; i < count; i++) + { + reviews.Add(new UserReviewDto + { + Tagline = $"{i + 1}", + Score = count - i // This makes them ordered by score descending initially + }); + } + return reviews; + } + + #endregion +} + diff --git a/API.Tests/Helpers/ScannerHelper.cs b/API.Tests/Helpers/ScannerHelper.cs new file mode 100644 index 000000000..653efebb1 --- /dev/null +++ b/API.Tests/Helpers/ScannerHelper.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Serialization; +using API.Data; +using API.Data.Metadata; +using API.Entities; +using API.Entities.Enums; +using API.Helpers; +using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; +using API.Services.Tasks; +using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner; +using API.SignalR; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit.Abstractions; + +namespace API.Tests.Helpers; +#nullable enable + +public class ScannerHelper +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ITestOutputHelper _testOutputHelper; + private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); + private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases"); + private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png"); + private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" }; + + public ScannerHelper(IUnitOfWork unitOfWork, ITestOutputHelper testOutputHelper) + { + _unitOfWork = unitOfWork; + _testOutputHelper = testOutputHelper; + } + + public async Task GenerateScannerData(string testcase, Dictionary comicInfos = null) + { + var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos); + + var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase)); + + var library = new LibraryBuilder(publisher, type) + .WithFolders([new FolderPath() {Path = testDirectoryPath}]) + .Build(); + + var admin = new AppUserBuilder("admin", "admin@kavita.com", Seed.DefaultThemes[0]) + .WithLibrary(library) + .Build(); + + _unitOfWork.UserRepository.Add(admin); // Admin is needed for generating collections/reading lists + _unitOfWork.LibraryRepository.Add(library); + await _unitOfWork.CommitAsync(); + + return library; + } + + public ScannerService CreateServices(DirectoryService ds = null, IFileSystem fs = null) + { + fs ??= new FileSystem(); + ds ??= new DirectoryService(Substitute.For>(), fs); + var archiveService = new ArchiveService(Substitute.For>(), ds, + Substitute.For(), Substitute.For()); + var readingItemService = new ReadingItemService(archiveService, Substitute.For(), + Substitute.For(), ds, Substitute.For>()); + + + var processSeries = new ProcessSeries(_unitOfWork, Substitute.For>(), + Substitute.For(), + ds, Substitute.For(), readingItemService, new FileService(fs), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For()); + + var scanner = new ScannerService(_unitOfWork, Substitute.For>(), + Substitute.For(), + Substitute.For(), Substitute.For(), ds, + readingItemService, processSeries, Substitute.For()); + return scanner; + } + + private static (string Publisher, LibraryType Type) SplitPublisherAndLibraryType(string input) + { + // Split the input string based on " - " + var parts = input.Split(" - ", StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length != 2) + { + throw new ArgumentException("Input must be in the format 'Publisher - LibraryType'"); + } + + var publisher = parts[0].Trim(); + var libraryTypeString = parts[1].Trim(); + + // Try to parse the right-hand side as a LibraryType enum + if (!Enum.TryParse(libraryTypeString, out var libraryType)) + { + throw new ArgumentException($"'{libraryTypeString}' is not a valid LibraryType"); + } + + return (publisher, libraryType); + } + + + + private async Task GenerateTestDirectory(string mapPath, Dictionary comicInfos = null) + { + // Read the map file + var mapContent = await File.ReadAllTextAsync(mapPath); + + // Deserialize the JSON content into a list of strings using System.Text.Json + var filePaths = JsonSerializer.Deserialize>(mapContent); + + // Create a test directory + var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(mapPath)); + if (Directory.Exists(testDirectory)) + { + Directory.Delete(testDirectory, true); + } + Directory.CreateDirectory(testDirectory); + + // Generate the files and folders + await Scaffold(testDirectory, filePaths, comicInfos); + + _testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}"); + + return Path.GetFullPath(testDirectory); + } + + + public async Task Scaffold(string testDirectory, List filePaths, Dictionary comicInfos = null) + { + foreach (var relativePath in filePaths) + { + var fullPath = Path.Combine(testDirectory, relativePath); + var fileDir = Path.GetDirectoryName(fullPath); + + // Create the directory if it doesn't exist + if (!Directory.Exists(fileDir)) + { + Directory.CreateDirectory(fileDir); + Console.WriteLine($"Created directory: {fileDir}"); + } + + var ext = Path.GetExtension(fullPath).ToLower(); + if (ComicInfoExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var info)) + { + CreateMinimalCbz(fullPath, info); + } + else + { + // Create an empty file + await File.Create(fullPath).DisposeAsync(); + Console.WriteLine($"Created empty file: {fullPath}"); + } + } + } + + private void CreateMinimalCbz(string filePath, ComicInfo? comicInfo = null) + { + using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create)) + { + // Add the 1x1 image to the archive + archive.CreateEntryFromFile(_imagePath, "1x1.png"); + + if (comicInfo != null) + { + // Serialize ComicInfo object to XML + var comicInfoXml = SerializeComicInfoToXml(comicInfo); + + // Create an entry for ComicInfo.xml in the archive + var entry = archive.CreateEntry("ComicInfo.xml"); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + + // Write the XML to the archive + writer.Write(comicInfoXml); + } + + } + Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata."); + } + + + private static string SerializeComicInfoToXml(ComicInfo comicInfo) + { + var xmlSerializer = new XmlSerializer(typeof(ComicInfo)); + using var stringWriter = new StringWriter(); + using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false), OmitXmlDeclaration = false})) + { + xmlSerializer.Serialize(xmlWriter, comicInfo); + } + + // For the love of god, I spent 2 hours trying to get utf-8 with no BOM + return stringWriter.ToString().Replace("""""", + @""); + } +} diff --git a/API.Tests/Helpers/SeriesHelperTests.cs b/API.Tests/Helpers/SeriesHelperTests.cs index a5b5a063b..22b4a3cd1 100644 --- a/API.Tests/Helpers/SeriesHelperTests.cs +++ b/API.Tests/Helpers/SeriesHelperTests.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Linq; -using API.Data; using API.Entities; using API.Entities.Enums; using API.Extensions; diff --git a/API.Tests/Helpers/StringHelperTests.cs b/API.Tests/Helpers/StringHelperTests.cs new file mode 100644 index 000000000..8f845c9b0 --- /dev/null +++ b/API.Tests/Helpers/StringHelperTests.cs @@ -0,0 +1,46 @@ +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class StringHelperTests +{ + [Theory] + [InlineData( + "

A Perfect Marriage Becomes a Perfect Affair!



Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?

", + "

A Perfect Marriage Becomes a Perfect Affair!
Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?

" + )] + [InlineData( + "

Blog | Twitter | Pixiv | Pawoo

", + "

Blog | Twitter | Pixiv | Pawoo

" + )] + public void TestSquashBreaklines(string input, string expected) + { + Assert.Equal(expected, StringHelper.SquashBreaklines(input)); + } + + [Theory] + [InlineData( + "

A Perfect Marriage Becomes a Perfect Affair!
(Source: Anime News Network)

", + "

A Perfect Marriage Becomes a Perfect Affair!

" + )] + [InlineData( + "

A Perfect Marriage Becomes a Perfect Affair!

(Source: Anime News Network)", + "

A Perfect Marriage Becomes a Perfect Affair!

" + )] + public void TestRemoveSourceInDescription(string input, string expected) + { + Assert.Equal(expected, StringHelper.RemoveSourceInDescription(input)); + } + + + [Theory] + [InlineData( +"""Pawoo

""", +"""Pawoo

""" + )] + public void TestCorrectUrls(string input, string expected) + { + Assert.Equal(expected, StringHelper.CorrectUrls(input)); + } +} diff --git a/API.Tests/Parsers/BasicParserTests.cs b/API.Tests/Parsers/BasicParserTests.cs index ad040d59e..32673e0e6 100644 --- a/API.Tests/Parsers/BasicParserTests.cs +++ b/API.Tests/Parsers/BasicParserTests.cs @@ -1,4 +1,5 @@ -using System.IO.Abstractions.TestingHelpers; +using System.IO; +using System.IO.Abstractions.TestingHelpers; using API.Entities.Enums; using API.Services; using API.Services.Tasks.Scanner.Parser; @@ -8,59 +9,54 @@ using Xunit; namespace API.Tests.Parsers; -public class BasicParserTests +public class BasicParserTests : AbstractFsTest { private readonly BasicParser _parser; private readonly ILogger _dsLogger = Substitute.For>(); - private const string RootDirectory = "C:/Books/"; + private readonly string _rootDirectory; public BasicParserTests() { - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory("C:/Books/"); - fileSystem.AddFile("C:/Books/Harry Potter/Harry Potter - Vol 1.epub", new MockFileData("")); + var fileSystem = CreateFileSystem(); + _rootDirectory = Path.Join(DataDirectory, "Books/"); + fileSystem.AddDirectory(_rootDirectory); + fileSystem.AddFile($"{_rootDirectory}Harry Potter/Harry Potter - Vol 1.epub", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World - Volume 1.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World - Volume 1 Chapter 2.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World - Chapter 3.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/Accel World Gaiden SP01.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Volume 1.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Volume 1 Chapter 2.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Chapter 3.cbz", new MockFileData("")); + fileSystem.AddFile("$\"{RootDirectory}Accel World/Accel World Gaiden SP01.cbz", new MockFileData("")); - fileSystem.AddFile("C:/Books/Accel World/cover.png", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/cover.png", new MockFileData("")); - fileSystem.AddFile("C:/Books/Batman/Batman #1.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Batman/Batman #1.cbz", new MockFileData("")); var ds = new DirectoryService(_dsLogger, fileSystem); _parser = new BasicParser(ds, new ImageParser(ds)); } - #region Parse_Books - - - - #endregion - #region Parse_Manga /// - /// Tests that when there is a loose leaf cover in the manga library, that it is ignored + /// Tests that when there is a loose-leaf cover in the manga library, that it is ignored /// [Fact] public void Parse_MangaLibrary_JustCover_ShouldReturnNull() { - var actual = _parser.Parse(@"C:/Books/Accel World/cover.png", "C:/Books/Accel World/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Accel World/cover.png", $"{_rootDirectory}Accel World/", + _rootDirectory, LibraryType.Manga); Assert.Null(actual); } /// - /// Tests that when there is a loose leaf cover in the manga library, that it is ignored + /// Tests that when there is a loose-leaf cover in the manga library, that it is ignored /// [Fact] public void Parse_MangaLibrary_OtherImage_ShouldReturnNull() { - var actual = _parser.Parse(@"C:/Books/Accel World/page 01.png", "C:/Books/Accel World/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Accel World/page 01.png", $"{_rootDirectory}Accel World/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); } @@ -70,8 +66,8 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_VolumeAndChapterInFilename() { - var actual = _parser.Parse("C:/Books/Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", "C:/Books/Mujaki no Rakuen/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", $"{_rootDirectory}Mujaki no Rakuen/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Mujaki no Rakuen", actual.Series); @@ -86,9 +82,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_JustVolumeInFilename() { - var actual = _parser.Parse("C:/Books/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz", - "C:/Books/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz", + $"{_rootDirectory}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", actual.Series); @@ -103,9 +99,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_JustChapterInFilename() { - var actual = _parser.Parse("C:/Books/Beelzebub/Beelzebub_01_[Noodles].zip", - "C:/Books/Beelzebub/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Beelzebub/Beelzebub_01_[Noodles].zip", + $"{_rootDirectory}Beelzebub/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Beelzebub", actual.Series); @@ -120,9 +116,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_SpecialMarkerInFilename() { - var actual = _parser.Parse("C:/Books/Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr", - "C:/Books/Summer Time Rendering/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr", + $"{_rootDirectory}Summer Time Rendering/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Summer Time Rendering", actual.Series); @@ -133,36 +129,54 @@ public class BasicParserTests /// - /// Tests that when the filename parses as a speical, it appropriately parses + /// Tests that when the filename parses as a special, it appropriately parses /// [Fact] public void Parse_MangaLibrary_SpecialInFilename() { - var actual = _parser.Parse("C:/Books/Summer Time Rendering/Volume SP01.cbr", - "C:/Books/Summer Time Rendering/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Volume SP01.cbr", + $"{_rootDirectory}Summer Time Rendering/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Summer Time Rendering", actual.Series); - Assert.Equal("Volume SP01", actual.Title); + Assert.Equal("Volume", actual.Title); Assert.Equal(Parser.SpecialVolume, actual.Volumes); Assert.Equal(Parser.DefaultChapter, actual.Chapters); Assert.True(actual.IsSpecial); } /// - /// Tests that when the filename parses as a speical, it appropriately parses + /// Tests that when the filename parses as a special, it appropriately parses /// [Fact] public void Parse_MangaLibrary_SpecialInFilename2() { var actual = _parser.Parse("M:/Kimi wa Midara na Boku no Joou/Specials/[Renzokusei] Special 1 SP02.zip", "M:/Kimi wa Midara na Boku no Joou/", - RootDirectory, LibraryType.Manga, null); + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Kimi wa Midara na Boku no Joou", actual.Series); - Assert.Equal("[Renzokusei] Special 1 SP02", actual.Title); + Assert.Equal("[Renzokusei] Special 1", actual.Title); + Assert.Equal(Parser.SpecialVolume, actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.True(actual.IsSpecial); + } + + /// + /// Tests that when the filename parses as a special, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_SpecialInFilename_StrangeNaming() + { + var actual = _parser.Parse($"{_rootDirectory}My Dress-Up Darling/SP01 1. Special Name.cbz", + _rootDirectory, + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("My Dress-Up Darling", actual.Series); + Assert.Equal("1. Special Name", actual.Title); Assert.Equal(Parser.SpecialVolume, actual.Volumes); Assert.Equal(Parser.DefaultChapter, actual.Chapters); Assert.True(actual.IsSpecial); @@ -174,9 +188,9 @@ public class BasicParserTests [Fact] public void Parse_MangaLibrary_EditionInFilename() { - var actual = _parser.Parse("C:/Books/Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", - "C:/Books/Air Gear/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", + $"{_rootDirectory}Air Gear/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Air Gear", actual.Series); @@ -195,9 +209,9 @@ public class BasicParserTests [Fact] public void Parse_MangaBooks_JustVolumeInFilename() { - var actual = _parser.Parse("C:/Books/Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", - "C:/Books/Epubs/", - RootDirectory, LibraryType.Manga, null); + var actual = _parser.Parse($"{_rootDirectory}Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", + $"{_rootDirectory}Epubs/", + _rootDirectory, LibraryType.Manga); Assert.NotNull(actual); Assert.Equal("Harrison, Kim - The Good, The Bad, and the Undead - Hollows", actual.Series); diff --git a/API.Tests/Parsers/BookParserTests.cs b/API.Tests/Parsers/BookParserTests.cs index 6be0fe386..90147ac6b 100644 --- a/API.Tests/Parsers/BookParserTests.cs +++ b/API.Tests/Parsers/BookParserTests.cs @@ -1,5 +1,4 @@ using System.IO.Abstractions.TestingHelpers; -using API.Data.Metadata; using API.Entities.Enums; using API.Services; using API.Services.Tasks.Scanner.Parser; diff --git a/API.Tests/Parsers/ComicVineParserTests.cs b/API.Tests/Parsers/ComicVineParserTests.cs index f01e98afd..2f4fd568e 100644 --- a/API.Tests/Parsers/ComicVineParserTests.cs +++ b/API.Tests/Parsers/ComicVineParserTests.cs @@ -36,7 +36,7 @@ public class ComicVineParserTests public void Parse_SeriesWithComicInfo() { var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/", - RootDirectory, LibraryType.ComicVine, new ComicInfo() + RootDirectory, LibraryType.ComicVine, true, new ComicInfo() { Series = "Birds of Prey", Volume = "2002" @@ -54,7 +54,7 @@ public class ComicVineParserTests public void Parse_SeriesWithDirectoryNameAsSeriesYear() { var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey (2002)", actual.Series); @@ -69,7 +69,7 @@ public class ComicVineParserTests public void Parse_SeriesWithADirectoryNameAsSeriesYear() { var actual = _parser.Parse("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", "C:/Comics/DC Comics/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey (1999)", actual.Series); @@ -84,7 +84,7 @@ public class ComicVineParserTests public void Parse_FallbackToDirectoryNameOnly() { var actual = _parser.Parse("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", "C:/Comics/DC Comics/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Blood Syndicate", actual.Series); diff --git a/API.Tests/Parsers/DefaultParserTests.cs b/API.Tests/Parsers/DefaultParserTests.cs index 733b55d62..244c08b97 100644 --- a/API.Tests/Parsers/DefaultParserTests.cs +++ b/API.Tests/Parsers/DefaultParserTests.cs @@ -33,7 +33,7 @@ public class DefaultParserTests [InlineData("C:/", "C:/Something Random/Mujaki no Rakuen SP01.cbz", "Something Random")] public void ParseFromFallbackFolders_FallbackShouldParseSeries(string rootDir, string inputPath, string expectedSeries) { - var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, null); + var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, true, null); if (actual == null) { Assert.NotNull(actual); @@ -74,7 +74,7 @@ public class DefaultParserTests fs.AddFile(inputFile, new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fs); var parser = new BasicParser(ds, new ImageParser(ds)); - var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null); + var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null); _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); Assert.Equal(expectedParseInfo, actual.Series); } @@ -90,7 +90,7 @@ public class DefaultParserTests fs.AddFile(inputFile, new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fs); var parser = new BasicParser(ds, new ImageParser(ds)); - var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null); + var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null); _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); Assert.Equal(expectedParseInfo, actual.Series); } @@ -251,7 +251,7 @@ public class DefaultParserTests foreach (var file in expected.Keys) { var expectedInfo = expected[file]; - var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, null); + var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, true, null); if (expectedInfo == null) { Assert.Null(actual); @@ -289,7 +289,7 @@ public class DefaultParserTests Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image, FullFilePath = filepath, IsSpecial = false }; - var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, null); + var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -315,7 +315,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, null); + actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -341,7 +341,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, null); + actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -383,7 +383,7 @@ public class DefaultParserTests FullFilePath = filepath }; - var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null); + var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null); Assert.NotNull(actual); _testOutputHelper.WriteLine($"Validating {filepath}"); @@ -412,7 +412,7 @@ public class DefaultParserTests FullFilePath = filepath }; - actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null); + actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null); Assert.NotNull(actual); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expected.Format, actual.Format); @@ -475,7 +475,7 @@ public class DefaultParserTests foreach (var file in expected.Keys) { var expectedInfo = expected[file]; - var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, null); + var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, true, null); if (expectedInfo == null) { Assert.Null(actual); diff --git a/API.Tests/Parsers/ImageParserTests.cs b/API.Tests/Parsers/ImageParserTests.cs index f95c98ddf..63df1926e 100644 --- a/API.Tests/Parsers/ImageParserTests.cs +++ b/API.Tests/Parsers/ImageParserTests.cs @@ -34,7 +34,7 @@ public class ImageParserTests public void Parse_SeriesWithDirectoryName() { var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01/01.jpg", "C:/Comics/Birds of Prey/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); @@ -48,7 +48,7 @@ public class ImageParserTests public void Parse_SeriesWithNoNestedChapter() { var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01 page 01.jpg", "C:/Comics/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); @@ -62,7 +62,7 @@ public class ImageParserTests public void Parse_SeriesWithLooseImages() { var actual = _parser.Parse("C:/Comics/Birds of Prey/page 01.jpg", "C:/Comics/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); diff --git a/API.Tests/Parsers/PdfParserTests.cs b/API.Tests/Parsers/PdfParserTests.cs index 72088526d..08bf9f25d 100644 --- a/API.Tests/Parsers/PdfParserTests.cs +++ b/API.Tests/Parsers/PdfParserTests.cs @@ -35,7 +35,7 @@ public class PdfParserTests { var actual = _parser.Parse("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf", "C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/", - RootDirectory, LibraryType.Book, null); + RootDirectory, LibraryType.Book, true, null); Assert.NotNull(actual); Assert.Equal("A Dictionary of Japanese Food - Ingredients and Culture", actual.Series); diff --git a/API.Tests/Parsing/ComicParsingTests.cs b/API.Tests/Parsing/ComicParsingTests.cs index ad28e80a9..a0375a566 100644 --- a/API.Tests/Parsing/ComicParsingTests.cs +++ b/API.Tests/Parsing/ComicParsingTests.cs @@ -51,15 +51,15 @@ public class ComicParsingTests [InlineData("Demon 012 (Sep 1973) c2c", "Demon")] [InlineData("Dragon Age - Until We Sleep 01 (of 03)", "Dragon Age - Until We Sleep")] [InlineData("Green Lantern v2 017 - The Spy-Eye that doomed Green Lantern v2", "Green Lantern")] - [InlineData("Green Lantern - Circle of Fire Special - Adam Strange (2000)", "Green Lantern - Circle of Fire - Adam Strange")] - [InlineData("Identity Crisis Extra - Rags Morales Sketches (2005)", "Identity Crisis - Rags Morales Sketches")] + [InlineData("Green Lantern - Circle of Fire Special - Adam Strange (2000)", "Green Lantern - Circle of Fire Special - Adam Strange")] + [InlineData("Identity Crisis Extra - Rags Morales Sketches (2005)", "Identity Crisis Extra - Rags Morales Sketches")] [InlineData("Daredevil - t6 - 10 - (2019)", "Daredevil")] [InlineData("Batgirl T2000 #57", "Batgirl")] [InlineData("Teen Titans t1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "Teen Titans")] [InlineData("Conquistador_-Tome_2", "Conquistador")] [InlineData("Max_l_explorateur-_Tome_0", "Max l explorateur")] [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "Chevaliers d'Héliopolis")] - [InlineData("Bd Fr-Aldebaran-Antares-t6", "Aldebaran-Antares")] + [InlineData("Bd Fr-Aldebaran-Antares-t6", "Bd Fr-Aldebaran-Antares")] [InlineData("Tintin - T22 Vol 714 pour Sydney", "Tintin")] [InlineData("Fables 2010 Vol. 1 Legends in Exile", "Fables 2010")] [InlineData("Kebab Том 1 Глава 1", "Kebab")] diff --git a/API.Tests/Parsing/ImageParsingTests.cs b/API.Tests/Parsing/ImageParsingTests.cs index 3d78d9372..362b4b08c 100644 --- a/API.Tests/Parsing/ImageParsingTests.cs +++ b/API.Tests/Parsing/ImageParsingTests.cs @@ -34,7 +34,7 @@ public class ImageParsingTests Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image, FullFilePath = filepath, IsSpecial = false }; - var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, null); + var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -60,7 +60,7 @@ public class ImageParsingTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null); + actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -86,7 +86,7 @@ public class ImageParsingTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null); + actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index 852eedb9e..53f2bc4c9 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -1,18 +1,10 @@ using API.Entities.Enums; using Xunit; -using Xunit.Abstractions; namespace API.Tests.Parsing; public class MangaParsingTests { - private readonly ITestOutputHelper _testOutputHelper; - - public MangaParsingTests(ITestOutputHelper testOutputHelper) - { - _testOutputHelper = testOutputHelper; - } - [Theory] [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "1")] @@ -76,7 +68,6 @@ public class MangaParsingTests [InlineData("Манга Тома 1-4", "1-4")] [InlineData("Манга Том 1-4", "1-4")] [InlineData("조선왕조실톡 106화", "106")] - [InlineData("죽음 13회", "13")] [InlineData("동의보감 13장", "13")] [InlineData("몰?루 아카이브 7.5권", "7.5")] [InlineData("63권#200", "63")] @@ -84,6 +75,7 @@ public class MangaParsingTests [InlineData("Accel World Chapter 001 Volume 002", "2")] [InlineData("Accel World Volume 2", "2")] [InlineData("Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.31 Omake", "30")] + [InlineData("Zom 100 - Bucket List of the Dead v01", "1")] public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename, LibraryType.Manga)); @@ -139,7 +131,6 @@ public class MangaParsingTests [InlineData("Vagabond_v03", "Vagabond")] [InlineData("[AN] Mahoutsukai to Deshi no Futekisetsu na Kankei Chp. 1", "Mahoutsukai to Deshi no Futekisetsu na Kankei")] [InlineData("Beelzebub_Side_Story_02_RHS.zip", "Beelzebub Side Story")] - [InlineData("[BAA]_Darker_than_Black_Omake-1.zip", "Darker than Black")] [InlineData("Baketeriya ch01-05.zip", "Baketeriya")] [InlineData("[PROzess]Kimi_ha_midara_na_Boku_no_Joou_-_Ch01", "Kimi ha midara na Boku no Joou")] [InlineData("[SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar", "NEEDLESS")] @@ -212,6 +203,9 @@ public class MangaParsingTests [InlineData("หนึ่งความคิด นิจนิรันดร์ เล่ม 2", "หนึ่งความคิด นิจนิรันดร์")] [InlineData("不安の種\uff0b - 01", "不安の種\uff0b")] [InlineData("Giant Ojou-sama - Ch. 33.5 - Volume 04 Bonus Chapter", "Giant Ojou-sama")] + [InlineData("[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE", "")] + [InlineData("Monster #8 Ch. 001", "Monster #8")] + [InlineData("Zom 100 - Bucket List of the Dead v01", "Zom 100 - Bucket List of the Dead")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Manga)); @@ -304,6 +298,7 @@ public class MangaParsingTests [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "3")] [InlineData("Max Level Returner ตอนที่ 5", "5")] [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")] + [InlineData("Monster #8 Ch. 001", "1")] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename, LibraryType.Manga)); diff --git a/API.Tests/Parsing/ParserInfoTests.cs b/API.Tests/Parsing/ParserInfoTests.cs index 61ae8ecf2..cbb8ae99a 100644 --- a/API.Tests/Parsing/ParserInfoTests.cs +++ b/API.Tests/Parsing/ParserInfoTests.cs @@ -11,14 +11,14 @@ public class ParserInfoTests { var p1 = new ParserInfo() { - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + Chapters = Parser.DefaultChapter, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/darker than black.cbz", IsSpecial = false, Series = "darker than black", Title = "darker than black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; var p2 = new ParserInfo() @@ -30,7 +30,7 @@ public class ParserInfoTests IsSpecial = false, Series = "darker than black", Title = "Darker Than Black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; var expected = new ParserInfo() @@ -42,7 +42,7 @@ public class ParserInfoTests IsSpecial = false, Series = "darker than black", Title = "darker than black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; p1.Merge(p2); @@ -62,12 +62,12 @@ public class ParserInfoTests IsSpecial = true, Series = "darker than black", Title = "darker than black", - Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + Volumes = Parser.LooseLeafVolume }; var p2 = new ParserInfo() { - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + Chapters = Parser.DefaultChapter, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/darker than black.cbz", diff --git a/API.Tests/Parsing/ParsingTests.cs b/API.Tests/Parsing/ParsingTests.cs index e30bf6554..7d5da4f9c 100644 --- a/API.Tests/Parsing/ParsingTests.cs +++ b/API.Tests/Parsing/ParsingTests.cs @@ -10,11 +10,25 @@ public class ParsingTests [Fact] public void ShouldWork() { - var s = 6.5f + ""; + var s = 6.5f.ToString(CultureInfo.InvariantCulture); var a = float.Parse(s, CultureInfo.InvariantCulture); Assert.Equal(6.5f, a); + + s = 6.5f + ""; + a = float.Parse(s, CultureInfo.CurrentCulture); + Assert.Equal(6.5f, a); } + // [Theory] + // [InlineData("de-DE")] + // [InlineData("en-US")] + // public void ShouldParse(string culture) + // { + // var s = 6.5f + ""; + // var a = float.Parse(s, CultureInfo.CreateSpecificCulture(culture)); + // Assert.Equal(6.5f, a); + // } + [Theory] [InlineData("Joe Shmo, Green Blue", "Joe Shmo, Green Blue")] [InlineData("Shmo, Joe", "Shmo, Joe")] @@ -29,6 +43,7 @@ public class ParsingTests [InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")] [InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")] [InlineData("DEAD_Tube_Prologue SP01", "DEAD Tube Prologue")] + [InlineData("SP01 1. DEAD Tube Prologue", "1. DEAD Tube Prologue")] public void CleanSpecialTitleTest(string input, string expected) { Assert.Equal(expected, CleanSpecialTitle(input)); @@ -83,7 +98,8 @@ public class ParsingTests [InlineData("-The Title", false, "The Title")] [InlineData("- The Title", false, "The Title")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1", false, "Kasumi Otoko no Ko v1.1")] - [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 04 (2019) (digital) (Son of Ultron-Empire)", true, "Batman - Detective Comics - Rebirth Deluxe Edition")] + [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 04 (2019) (digital) (Son of Ultron-Empire)", + true, "Batman - Detective Comics - Rebirth Deluxe Edition Book 04")] [InlineData("Something - Full Color Edition", false, "Something - Full Color Edition")] [InlineData("Witchblade 089 (2005) (Bittertek-DCP) (Top Cow (Image Comics))", true, "Witchblade 089")] [InlineData("(C99) Kami-sama Hiroimashita. (SSSS.GRIDMAN)", false, "Kami-sama Hiroimashita.")] @@ -235,6 +251,7 @@ public class ParsingTests [InlineData("ch1/backcover.png", false)] [InlineData("backcover.png", false)] [InlineData("back_cover.png", false)] + [InlineData("LD Blacklands #1 35 (back cover).png", false)] public void IsCoverImageTest(string inputPath, bool expected) { Assert.Equal(expected, IsCoverImage(inputPath)); diff --git a/API.Tests/Repository/CollectionTagRepositoryTests.cs b/API.Tests/Repository/CollectionTagRepositoryTests.cs index 6abf3f7e7..5318260be 100644 --- a/API.Tests/Repository/CollectionTagRepositoryTests.cs +++ b/API.Tests/Repository/CollectionTagRepositoryTests.cs @@ -15,7 +15,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging; using NSubstitute; -using Xunit; namespace API.Tests.Repository; diff --git a/API.Tests/Repository/GenreRepositoryTests.cs b/API.Tests/Repository/GenreRepositoryTests.cs new file mode 100644 index 000000000..d197a91ba --- /dev/null +++ b/API.Tests/Repository/GenreRepositoryTests.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class GenreRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Genre.RemoveRange(Context.Genre); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + private TestGenreSet CreateTestGenres() + { + return new TestGenreSet + { + SharedSeriesChaptersGenre = new GenreBuilder("Shared Series Chapter Genre").Build(), + SharedSeriesGenre = new GenreBuilder("Shared Series Genre").Build(), + SharedChaptersGenre = new GenreBuilder("Shared Chapters Genre").Build(), + Lib0SeriesChaptersGenre = new GenreBuilder("Lib0 Series Chapter Genre").Build(), + Lib0SeriesGenre = new GenreBuilder("Lib0 Series Genre").Build(), + Lib0ChaptersGenre = new GenreBuilder("Lib0 Chapters Genre").Build(), + Lib1SeriesChaptersGenre = new GenreBuilder("Lib1 Series Chapter Genre").Build(), + Lib1SeriesGenre = new GenreBuilder("Lib1 Series Genre").Build(), + Lib1ChaptersGenre = new GenreBuilder("Lib1 Chapters Genre").Build(), + Lib1ChapterAgeGenre = new GenreBuilder("Lib1 Chapter Age Genre").Build() + }; + } + + private async Task SeedDbWithGenres(TestGenreSet genres) + { + await CreateTestUsers(); + await AddGenresToContext(genres); + await CreateLibrariesWithGenres(genres); + await AssignLibrariesToUsers(); + } + + private async Task CreateTestUsers() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.Users.Add(_fullAccess); + Context.Users.Add(_restrictedAccess); + Context.Users.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + } + + private async Task AddGenresToContext(TestGenreSet genres) + { + var allGenres = genres.GetAllGenres(); + Context.Genre.AddRange(allGenres); + await Context.SaveChangesAsync(); + } + + private async Task CreateLibrariesWithGenres(TestGenreSet genres) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0SeriesGenre]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre, genres.Lib1ChapterAgeGenre]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .Build()) + .Build()) + .Build(); + + Context.Library.Add(lib0); + Context.Library.Add(lib1); + await Context.SaveChangesAsync(); + } + + private async Task AssignLibrariesToUsers() + { + var lib0 = Context.Library.First(l => l.Name == "lib0"); + var lib1 = Context.Library.First(l => l.Name == "lib1"); + + _fullAccess.Libraries.Add(lib0); + _fullAccess.Libraries.Add(lib1); + _restrictedAccess.Libraries.Add(lib1); + _restrictedAgeAccess.Libraries.Add(lib1); + + await Context.SaveChangesAsync(); + } + + private static Predicate ContainsGenreCheck(Genre genre) + { + return g => g.Id == genre.Id; + } + + private static void AssertGenrePresent(IEnumerable genres, Genre expectedGenre) + { + Assert.Contains(genres, ContainsGenreCheck(expectedGenre)); + } + + private static void AssertGenreNotPresent(IEnumerable genres, Genre expectedGenre) + { + Assert.DoesNotContain(genres, ContainsGenreCheck(expectedGenre)); + } + + private static BrowseGenreDto GetGenreDto(IEnumerable genres, Genre genre) + { + return genres.First(dto => dto.Id == genre.Id); + } + + [Fact] + public async Task GetBrowseableGenre_FullAccess_ReturnsAllGenresWithCorrectCounts() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var fullAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_fullAccess.Id, new UserParams()); + + // Assert + Assert.Equal(genres.GetAllGenres().Count, fullAccessGenres.TotalCount); + + foreach (var genre in genres.GetAllGenres()) + { + AssertGenrePresent(fullAccessGenres, genre); + } + + // Verify counts - 1 lib0 series, 2 lib1 series = 3 total series + Assert.Equal(3, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(6, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(1, GetGenreDto(fullAccessGenres, genres.Lib0SeriesGenre).SeriesCount); + } + + [Fact] + public async Task GetBrowseableGenre_RestrictedAccess_ReturnsOnlyAccessibleGenres() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var restrictedAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 4 library 1 specific = 7 genres + Assert.Equal(7, restrictedAccessGenres.TotalCount); + + // Verify shared and Library 1 genres are present + AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesGenre); + AssertGenrePresent(restrictedAccessGenres, genres.SharedChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChapterAgeGenre); + + // Verify Library 0 specific genres are not present + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesChaptersGenre); + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesGenre); + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0ChaptersGenre); + + // Verify counts - 2 lib1 series + Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.Lib1SeriesGenre).SeriesCount); + Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.Lib1ChaptersGenre).ChapterCount); + Assert.Equal(1, GetGenreDto(restrictedAccessGenres, genres.Lib1ChapterAgeGenre).ChapterCount); + } + + [Fact] + public async Task GetBrowseableGenre_RestrictedAgeAccess_FiltersAgeRestrictedContent() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var restrictedAgeAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAgeAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 3 lib1 specific = 6 genres (age-restricted genre filtered out) + Assert.Equal(6, restrictedAgeAccessGenres.TotalCount); + + // Verify accessible genres are present + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre); + + // Verify age-restricted genre is filtered out + AssertGenreNotPresent(restrictedAgeAccessGenres, genres.Lib1ChapterAgeGenre); + + // Verify counts - 1 series lib1 (age-restricted series filtered out) + Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1SeriesGenre).SeriesCount); + + // These values represent a bug - chapters are not properly filtered when their series is age-restricted + // Should be 2, but currently returns 3 due to the filtering issue + Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre).ChapterCount); + } + + private class TestGenreSet + { + public Genre SharedSeriesChaptersGenre { get; set; } + public Genre SharedSeriesGenre { get; set; } + public Genre SharedChaptersGenre { get; set; } + public Genre Lib0SeriesChaptersGenre { get; set; } + public Genre Lib0SeriesGenre { get; set; } + public Genre Lib0ChaptersGenre { get; set; } + public Genre Lib1SeriesChaptersGenre { get; set; } + public Genre Lib1SeriesGenre { get; set; } + public Genre Lib1ChaptersGenre { get; set; } + public Genre Lib1ChapterAgeGenre { get; set; } + + public List GetAllGenres() + { + return + [ + SharedSeriesChaptersGenre, SharedSeriesGenre, SharedChaptersGenre, + Lib0SeriesChaptersGenre, Lib0SeriesGenre, Lib0ChaptersGenre, + Lib1SeriesChaptersGenre, Lib1SeriesGenre, Lib1ChaptersGenre, Lib1ChapterAgeGenre + ]; + } + } +} diff --git a/API.Tests/Repository/PersonRepositoryTests.cs b/API.Tests/Repository/PersonRepositoryTests.cs new file mode 100644 index 000000000..a2b19cc0c --- /dev/null +++ b/API.Tests/Repository/PersonRepositoryTests.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class PersonRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); + Context.AppUser.RemoveRange(Context.AppUser.ToList()); + await UnitOfWork.CommitAsync(); + } + + private async Task SeedDb() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.AppUser.Add(_fullAccess); + Context.AppUser.Add(_restrictedAccess); + Context.AppUser.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + + var people = CreateTestPeople(); + Context.Person.AddRange(people); + await Context.SaveChangesAsync(); + + var libraries = CreateTestLibraries(people); + Context.Library.AddRange(libraries); + await Context.SaveChangesAsync(); + + _fullAccess.Libraries.Add(libraries[0]); // lib0 + _fullAccess.Libraries.Add(libraries[1]); // lib1 + _restrictedAccess.Libraries.Add(libraries[1]); // lib1 only + _restrictedAgeAccess.Libraries.Add(libraries[1]); // lib1 only + + await Context.SaveChangesAsync(); + } + + private static List CreateTestPeople() + { + return new List + { + new PersonBuilder("Shared Series Chapter Person").Build(), + new PersonBuilder("Shared Series Person").Build(), + new PersonBuilder("Shared Chapters Person").Build(), + new PersonBuilder("Lib0 Series Chapter Person").Build(), + new PersonBuilder("Lib0 Series Person").Build(), + new PersonBuilder("Lib0 Chapters Person").Build(), + new PersonBuilder("Lib1 Series Chapter Person").Build(), + new PersonBuilder("Lib1 Series Person").Build(), + new PersonBuilder("Lib1 Chapters Person").Build(), + new PersonBuilder("Lib1 Chapter Age Person").Build() + }; + } + + private static List CreateTestLibraries(List people) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Lib0 Series Person"), PersonRole.Writer) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Colorist) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Editor) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Letterer) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Imprint) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Chapter Age Person"), PersonRole.CoverArtist) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Inker) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Team) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Translator) + .Build()) + .Build()) + .Build()) + .Build(); + + return new List { lib0, lib1 }; + } + + private static Person GetPersonByName(List people, string name) + { + return people.First(p => p.Name == name); + } + + private Person GetPersonByName(string name) + { + return Context.Person.First(p => p.Name == name); + } + + private static Predicate ContainsPersonCheck(Person person) + { + return p => p.Id == person.Id; + } + + [Fact] + public async Task GetBrowsePersonDtos() + { + await ResetDb(); + await SeedDb(); + + // Get people from database for assertions + var sharedSeriesChaptersPerson = GetPersonByName("Shared Series Chapter Person"); + var lib0SeriesPerson = GetPersonByName("Lib0 Series Person"); + var lib1SeriesPerson = GetPersonByName("Lib1 Series Person"); + var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person"); + var allPeople = Context.Person.ToList(); + + var fullAccessPeople = + await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_fullAccess.Id, new BrowsePersonFilterDto(), + new UserParams()); + Assert.Equal(allPeople.Count, fullAccessPeople.TotalCount); + + foreach (var person in allPeople) + Assert.Contains(fullAccessPeople, ContainsPersonCheck(person)); + + // 1 series in lib0, 2 series in lib1 + Assert.Equal(3, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount); + // 3 series with each 2 chapters + Assert.Equal(6, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount); + // 1 series in lib0 + Assert.Equal(1, fullAccessPeople.First(dto => dto.Id == lib0SeriesPerson.Id).SeriesCount); + // 2 series in lib1 + Assert.Equal(2, fullAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount); + + var restrictedAccessPeople = + await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAccess.Id, new BrowsePersonFilterDto(), + new UserParams()); + + Assert.Equal(7, restrictedAccessPeople.TotalCount); + + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Chapter Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Chapters Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Chapter Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapters Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapter Age Person"))); + + // 2 series in lib1, no series in lib0 + Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount); + // 2 series with each 2 chapters + Assert.Equal(4, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount); + // 2 series in lib1 + Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount); + + var restrictedAgeAccessPeople = await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAgeAccess.Id, + new BrowsePersonFilterDto(), new UserParams()); + + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Equal(6, restrictedAgeAccessPeople.TotalCount); + + // No access to the age restricted chapter + Assert.DoesNotContain(restrictedAgeAccessPeople, ContainsPersonCheck(lib1ChapterAgePerson)); + } + + [Fact] + public async Task GetRolesForPersonByName() + { + await ResetDb(); + await SeedDb(); + + var sharedSeriesPerson = GetPersonByName("Shared Series Person"); + var sharedChaptersPerson = GetPersonByName("Shared Chapters Person"); + var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person"); + + var sharedSeriesRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _fullAccess.Id); + var chapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _fullAccess.Id); + var ageChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _fullAccess.Id); + Assert.Equal(3, sharedSeriesRoles.Count()); + Assert.Equal(6, chapterRoles.Count()); + Assert.Single(ageChapterRoles); + + var restrictedRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAccess.Id); + var restrictedChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAccess.Id); + var restrictedAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAccess.Id); + Assert.Equal(2, restrictedRoles.Count()); + Assert.Equal(4, restrictedChapterRoles.Count()); + Assert.Single(restrictedAgePersonChapterRoles); + + var restrictedAgeRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAgeAccess.Id); + var restrictedAgeChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAgeAccess.Id); + var restrictedAgeAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAgeAccess.Id); + Assert.Single(restrictedAgeRoles); + Assert.Equal(2, restrictedAgeChapterRoles.Count()); + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Empty(restrictedAgeAgePersonChapterRoles); + } + + [Fact] + public async Task GetPersonDtoByName() + { + await ResetDb(); + await SeedDb(); + + var allPeople = Context.Person.ToList(); + + foreach (var person in allPeople) + { + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName(person.Name, _fullAccess.Id)); + } + + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Shared Series Person", _restrictedAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAccess.Id)); + + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAgeAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAgeAccess.Id)); + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Chapter Age Person", _restrictedAgeAccess.Id)); + } + + [Fact] + public async Task GetSeriesKnownFor() + { + await ResetDb(); + await SeedDb(); + + var sharedSeriesPerson = GetPersonByName("Shared Series Person"); + var lib1SeriesPerson = GetPersonByName("Lib1 Series Person"); + + var series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _fullAccess.Id); + Assert.Equal(3, series.Count()); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAccess.Id); + Assert.Equal(2, series.Count()); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAgeAccess.Id); + Assert.Single(series); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(lib1SeriesPerson.Id, _restrictedAgeAccess.Id); + Assert.Single(series); + } + + [Fact] + public async Task GetChaptersForPersonByRole() + { + await ResetDb(); + await SeedDb(); + + var sharedChaptersPerson = GetPersonByName("Shared Chapters Person"); + + // Lib0 + var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Colorist); + var restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Colorist); + var restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Colorist); + Assert.Single(chapters); + Assert.Empty(restrictedChapters); + Assert.Empty(restrictedAgeChapters); + + // Lib1 - age restricted series + chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Imprint); + restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Imprint); + restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Imprint); + Assert.Single(chapters); + Assert.Single(restrictedChapters); + Assert.Empty(restrictedAgeChapters); + + // Lib1 - not age restricted series + chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Team); + restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Team); + restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Team); + Assert.Single(chapters); + Assert.Single(restrictedChapters); + Assert.Single(restrictedAgeChapters); + } +} diff --git a/API.Tests/Repository/SeriesRepositoryTests.cs b/API.Tests/Repository/SeriesRepositoryTests.cs index ec4b2a9f5..5705e1bc0 100644 --- a/API.Tests/Repository/SeriesRepositoryTests.cs +++ b/API.Tests/Repository/SeriesRepositoryTests.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using API.Data; using API.Entities; using API.Entities.Enums; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; @@ -159,4 +158,6 @@ public class SeriesRepositoryTests } } + // TODO: GetSeriesDtoForLibraryIdV2Async Tests (On Deck) + } diff --git a/API.Tests/Repository/TagRepositoryTests.cs b/API.Tests/Repository/TagRepositoryTests.cs new file mode 100644 index 000000000..229082eb6 --- /dev/null +++ b/API.Tests/Repository/TagRepositoryTests.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class TagRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Tag.RemoveRange(Context.Tag); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + private TestTagSet CreateTestTags() + { + return new TestTagSet + { + SharedSeriesChaptersTag = new TagBuilder("Shared Series Chapter Tag").Build(), + SharedSeriesTag = new TagBuilder("Shared Series Tag").Build(), + SharedChaptersTag = new TagBuilder("Shared Chapters Tag").Build(), + Lib0SeriesChaptersTag = new TagBuilder("Lib0 Series Chapter Tag").Build(), + Lib0SeriesTag = new TagBuilder("Lib0 Series Tag").Build(), + Lib0ChaptersTag = new TagBuilder("Lib0 Chapters Tag").Build(), + Lib1SeriesChaptersTag = new TagBuilder("Lib1 Series Chapter Tag").Build(), + Lib1SeriesTag = new TagBuilder("Lib1 Series Tag").Build(), + Lib1ChaptersTag = new TagBuilder("Lib1 Chapters Tag").Build(), + Lib1ChapterAgeTag = new TagBuilder("Lib1 Chapter Age Tag").Build() + }; + } + + private async Task SeedDbWithTags(TestTagSet tags) + { + await CreateTestUsers(); + await AddTagsToContext(tags); + await CreateLibrariesWithTags(tags); + await AssignLibrariesToUsers(); + } + + private async Task CreateTestUsers() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.Users.Add(_fullAccess); + Context.Users.Add(_restrictedAccess); + Context.Users.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + } + + private async Task AddTagsToContext(TestTagSet tags) + { + var allTags = tags.GetAllTags(); + Context.Tag.AddRange(allTags); + await Context.SaveChangesAsync(); + } + + private async Task CreateLibrariesWithTags(TestTagSet tags) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadata + { + Tags = [tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib0SeriesChaptersTag, tags.Lib0SeriesTag] + }) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib0SeriesChaptersTag, tags.Lib0ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag, tags.Lib1ChapterAgeTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .Build(); + + Context.Library.Add(lib0); + Context.Library.Add(lib1); + await Context.SaveChangesAsync(); + } + + private async Task AssignLibrariesToUsers() + { + var lib0 = Context.Library.First(l => l.Name == "lib0"); + var lib1 = Context.Library.First(l => l.Name == "lib1"); + + _fullAccess.Libraries.Add(lib0); + _fullAccess.Libraries.Add(lib1); + _restrictedAccess.Libraries.Add(lib1); + _restrictedAgeAccess.Libraries.Add(lib1); + + await Context.SaveChangesAsync(); + } + + private static Predicate ContainsTagCheck(Tag tag) + { + return t => t.Id == tag.Id; + } + + private static void AssertTagPresent(IEnumerable tags, Tag expectedTag) + { + Assert.Contains(tags, ContainsTagCheck(expectedTag)); + } + + private static void AssertTagNotPresent(IEnumerable tags, Tag expectedTag) + { + Assert.DoesNotContain(tags, ContainsTagCheck(expectedTag)); + } + + private static BrowseTagDto GetTagDto(IEnumerable tags, Tag tag) + { + return tags.First(dto => dto.Id == tag.Id); + } + + [Fact] + public async Task GetBrowseableTag_FullAccess_ReturnsAllTagsWithCorrectCounts() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var fullAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_fullAccess.Id, new UserParams()); + + // Assert + Assert.Equal(tags.GetAllTags().Count, fullAccessTags.TotalCount); + + foreach (var tag in tags.GetAllTags()) + { + AssertTagPresent(fullAccessTags, tag); + } + + // Verify counts - 1 series lib0, 2 series lib1 = 3 total series + Assert.Equal(3, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(6, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(1, GetTagDto(fullAccessTags, tags.Lib0SeriesTag).SeriesCount); + } + + [Fact] + public async Task GetBrowseableTag_RestrictedAccess_ReturnsOnlyAccessibleTags() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var restrictedAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 4 library 1 specific = 7 tags + Assert.Equal(7, restrictedAccessTags.TotalCount); + + // Verify shared and Library 1 tags are present + AssertTagPresent(restrictedAccessTags, tags.SharedSeriesChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.SharedSeriesTag); + AssertTagPresent(restrictedAccessTags, tags.SharedChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1ChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1ChapterAgeTag); + + // Verify Library 0 specific tags are not present + AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesChaptersTag); + AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesTag); + AssertTagNotPresent(restrictedAccessTags, tags.Lib0ChaptersTag); + + // Verify counts - 2 series lib1 + Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.Lib1SeriesTag).SeriesCount); + Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.Lib1ChaptersTag).ChapterCount); + } + + [Fact] + public async Task GetBrowseableTag_RestrictedAgeAccess_FiltersAgeRestrictedContent() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var restrictedAgeAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAgeAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 3 lib1 specific = 6 tags (age-restricted tag filtered out) + Assert.Equal(6, restrictedAgeAccessTags.TotalCount); + + // Verify accessible tags are present + AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesTag); + AssertTagPresent(restrictedAgeAccessTags, tags.SharedChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1ChaptersTag); + + // Verify age-restricted tag is filtered out + AssertTagNotPresent(restrictedAgeAccessTags, tags.Lib1ChapterAgeTag); + + // Verify counts - 1 series lib1 (age-restricted series filtered out) + Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.Lib1SeriesTag).SeriesCount); + Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.Lib1ChaptersTag).ChapterCount); + } + + private class TestTagSet + { + public Tag SharedSeriesChaptersTag { get; set; } + public Tag SharedSeriesTag { get; set; } + public Tag SharedChaptersTag { get; set; } + public Tag Lib0SeriesChaptersTag { get; set; } + public Tag Lib0SeriesTag { get; set; } + public Tag Lib0ChaptersTag { get; set; } + public Tag Lib1SeriesChaptersTag { get; set; } + public Tag Lib1SeriesTag { get; set; } + public Tag Lib1ChaptersTag { get; set; } + public Tag Lib1ChapterAgeTag { get; set; } + + public List GetAllTags() + { + return + [ + SharedSeriesChaptersTag, SharedSeriesTag, SharedChaptersTag, + Lib0SeriesChaptersTag, Lib0SeriesTag, Lib0ChaptersTag, + Lib1SeriesChaptersTag, Lib1SeriesTag, Lib1ChaptersTag, Lib1ChapterAgeTag + ]; + } + } +} diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index 086d99863..8cf93df37 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -7,7 +7,6 @@ using System.Linq; using API.Archive; using API.Entities.Enums; using API.Services; -using EasyCaching.Core; using Microsoft.Extensions.Logging; using NetVips; using NSubstitute; @@ -29,7 +28,7 @@ public class ArchiveServiceTests { _testOutputHelper = testOutputHelper; _archiveService = new ArchiveService(_logger, _directoryService, - new ImageService(Substitute.For>(), _directoryService, Substitute.For()), + new ImageService(Substitute.For>(), _directoryService), Substitute.For()); } @@ -167,7 +166,7 @@ public class ArchiveServiceTests public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile) { var ds = Substitute.For(_directoryServiceLogger, new FileSystem()); - var imageService = new ImageService(Substitute.For>(), ds, Substitute.For()); + var imageService = new ImageService(Substitute.For>(), ds); var archiveService = Substitute.For(_logger, ds, imageService, Substitute.For()); var testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")); @@ -198,7 +197,7 @@ public class ArchiveServiceTests [InlineData("sorting.zip", "sorting.expected.png")] public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile) { - var imageService = new ImageService(Substitute.For>(), _directoryService, Substitute.For()); + var imageService = new ImageService(Substitute.For>(), _directoryService); var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService, Substitute.For()); diff --git a/API.Tests/Services/BackupServiceTests.cs b/API.Tests/Services/BackupServiceTests.cs index c4ca95a11..aac5724f7 100644 --- a/API.Tests/Services/BackupServiceTests.cs +++ b/API.Tests/Services/BackupServiceTests.cs @@ -1,10 +1,8 @@ -using System.Collections.Generic; -using System.Data.Common; +using System.Data.Common; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; -using API.Entities; using API.Entities.Enums; using API.Helpers.Builders; using API.Services; @@ -21,7 +19,7 @@ using Xunit; namespace API.Tests.Services; -public class BackupServiceTests +public class BackupServiceTests: AbstractFsTest { private readonly ILogger _logger = Substitute.For>(); private readonly IUnitOfWork _unitOfWork; @@ -31,13 +29,6 @@ public class BackupServiceTests private readonly DbConnection _connection; private readonly DataContext _context; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string LogDirectory = "C:/kavita/config/logs/"; - private const string ConfigDirectory = "C:/kavita/config/"; - private const string BookmarkDirectory = "C:/kavita/config/bookmarks"; - private const string ThemesDirectory = "C:/kavita/config/theme"; public BackupServiceTests() { @@ -82,7 +73,7 @@ public class BackupServiceTests _context.ServerSetting.Update(setting); _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); return await _context.SaveChangesAsync() > 0; } @@ -94,22 +85,6 @@ public class BackupServiceTests await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(LogDirectory); - fileSystem.AddDirectory(ThemesDirectory); - fileSystem.AddDirectory(BookmarkDirectory); - fileSystem.AddDirectory("C:/data/"); - - return fileSystem; - } - #endregion diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index e4647524e..5848c74ba 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -1,7 +1,8 @@ using System.IO; using System.IO.Abstractions; +using API.Entities.Enums; using API.Services; -using EasyCaching.Core; +using API.Services.Tasks.Scanner.Parser; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -17,7 +18,7 @@ public class BookServiceTests { var directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); _bookService = new BookService(_logger, directoryService, - new ImageService(Substitute.For>(), directoryService, Substitute.For()) + new ImageService(Substitute.For>(), directoryService) , Substitute.For()); } @@ -81,4 +82,64 @@ public class BookServiceTests Assert.Equal("Accel World", comicInfo.Series); } + [Fact] + public void ShouldHaveComicInfoForPdf() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "test.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal("Variations Chromatiques de concert", comicInfo.Title); + Assert.Equal("Georges Bizet \\(1838-1875\\)", comicInfo.Writer); + } + + //[Fact] + public void ShouldUsePdfInfoDict() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Library/Books/PDFs"); + var document = Path.Join(testDirectory, "Rollo at Work SP01.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal("Rollo at Work", comicInfo.Title); + Assert.Equal("Jacob Abbott", comicInfo.Writer); + Assert.Equal(2008, comicInfo.Year); + } + + [Fact] + public void ShouldHandleIndirectPdfObjects() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "indirect.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal(2018, comicInfo.Year); + Assert.Equal(8, comicInfo.Month); + } + + [Fact] + public void FailGracefullyWithEncryptedPdf() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "encrypted.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.Null(comicInfo); + } + + [Fact] + public void SeriesFallBackToMetadataTitle() + { + var ds = new DirectoryService(Substitute.For>(), new FileSystem()); + var pdfParser = new PdfParser(ds); + + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var filePath = Path.Join(testDirectory, "Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf"); + + var comicInfo = _bookService.GetComicInfo(filePath); + Assert.NotNull(comicInfo); + + var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, true, comicInfo); + Assert.NotNull(parserInfo); + Assert.Equal(parserInfo.Title, comicInfo.Title); + Assert.Equal(parserInfo.Series, comicInfo.Title); + } } diff --git a/API.Tests/Services/BookmarkServiceTests.cs b/API.Tests/Services/BookmarkServiceTests.cs index 80a483833..596fbbc4d 100644 --- a/API.Tests/Services/BookmarkServiceTests.cs +++ b/API.Tests/Services/BookmarkServiceTests.cs @@ -9,12 +9,9 @@ using API.Data.Repositories; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; -using API.SignalR; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -25,17 +22,12 @@ using Xunit; namespace API.Tests.Services; -public class BookmarkServiceTests +public class BookmarkServiceTests: AbstractFsTest { private readonly IUnitOfWork _unitOfWork; private readonly DbConnection _connection; private readonly DataContext _context; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; - public BookmarkServiceTests() { @@ -88,7 +80,7 @@ Substitute.For()); _context.ServerSetting.Update(setting); _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); return await _context.SaveChangesAsync() > 0; } @@ -102,20 +94,6 @@ Substitute.For()); await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(BookmarkDirectory); - fileSystem.AddDirectory("C:/data/"); - - return fileSystem; - } - #endregion #region BookmarkPage diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index ba06525a3..caf1ae393 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -1,12 +1,10 @@ -using System.Collections.Generic; -using System.Data.Common; +using System.Data.Common; using System.IO; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Metadata; -using API.Entities; using API.Entities.Enums; using API.Helpers.Builders; using API.Services; @@ -52,17 +50,17 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService throw new System.NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true) { throw new System.NotImplementedException(); } - public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true) { throw new System.NotImplementedException(); } } -public class CacheServiceTests +public class CacheServiceTests: AbstractFsTest { private readonly ILogger _logger = Substitute.For>(); private readonly IUnitOfWork _unitOfWork; @@ -71,11 +69,6 @@ public class CacheServiceTests private readonly DbConnection _connection; private readonly DataContext _context; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string DataDirectory = "C:/data/"; - public CacheServiceTests() { var contextOptions = new DbContextOptionsBuilder() @@ -118,7 +111,7 @@ public class CacheServiceTests _context.ServerSetting.Update(setting); _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); return await _context.SaveChangesAsync() > 0; } @@ -130,19 +123,6 @@ public class CacheServiceTests await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(DataDirectory); - - return fileSystem; - } - #endregion #region Ensure @@ -263,7 +243,7 @@ public class CacheServiceTests .WithFile(new MangaFileBuilder($"{DataDirectory}2.epub", MangaFormat.Epub).Build()) .Build(); cs.GetCachedFile(c); - Assert.Same($"{DataDirectory}1.epub", cs.GetCachedFile(c)); + Assert.Equal($"{DataDirectory}1.epub", cs.GetCachedFile(c)); } #endregion diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 2ebee8d1d..b0610aed5 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -1,16 +1,13 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; -using API.Data; using API.Data.Repositories; using API.DTOs.Filtering; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; using API.Extensions; using API.Helpers; using API.Helpers.Builders; @@ -30,14 +27,13 @@ public class CleanupServiceTests : AbstractDbTest private readonly IEventHub _messageHub = Substitute.For(); private readonly IReaderService _readerService; - public CleanupServiceTests() : base() { - _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + Context.Library.Add(new LibraryBuilder("Manga") + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); - _readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For(), + _readerService = new ReaderService(UnitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For()); } @@ -47,11 +43,11 @@ public class CleanupServiceTests : AbstractDbTest protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); - _context.Users.RemoveRange(_context.Users.ToList()); - _context.AppUserBookmark.RemoveRange(_context.AppUserBookmark.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); + Context.Users.RemoveRange(Context.Users.ToList()); + Context.AppUserBookmark.RemoveRange(Context.AppUserBookmark.ToList()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } #endregion @@ -72,18 +68,18 @@ public class CleanupServiceTests : AbstractDbTest var s = new SeriesBuilder("Test 1").Build(); s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg"; s.LibraryId = 1; - _context.Series.Add(s); + Context.Series.Add(s); s = new SeriesBuilder("Test 2").Build(); s.CoverImage = $"{ImageService.GetSeriesFormat(3)}.jpg"; s.LibraryId = 1; - _context.Series.Add(s); + Context.Series.Add(s); s = new SeriesBuilder("Test 3").Build(); s.CoverImage = $"{ImageService.GetSeriesFormat(1000)}.jpg"; s.LibraryId = 1; - _context.Series.Add(s); + Context.Series.Add(s); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.DeleteSeriesCoverImages(); @@ -106,16 +102,16 @@ public class CleanupServiceTests : AbstractDbTest var s = new SeriesBuilder("Test 1").Build(); s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg"; s.LibraryId = 1; - _context.Series.Add(s); + Context.Series.Add(s); s = new SeriesBuilder("Test 2").Build(); s.CoverImage = $"{ImageService.GetSeriesFormat(3)}.jpg"; s.LibraryId = 1; - _context.Series.Add(s); + Context.Series.Add(s); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.DeleteSeriesCoverImages(); @@ -137,7 +133,7 @@ public class CleanupServiceTests : AbstractDbTest await ResetDb(); // Add 2 series with cover images - _context.Series.Add(new SeriesBuilder("Test 1") + Context.Series.Add(new SeriesBuilder("Test 1") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithCoverImage("v01_c01.jpg").Build()) .WithCoverImage("v01_c01.jpg") @@ -146,7 +142,7 @@ public class CleanupServiceTests : AbstractDbTest .WithLibraryId(1) .Build()); - _context.Series.Add(new SeriesBuilder("Test 2") + Context.Series.Add(new SeriesBuilder("Test 2") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithCoverImage("v01_c03.jpg").Build()) .WithCoverImage("v01_c03.jpg") @@ -156,9 +152,9 @@ public class CleanupServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.DeleteChapterCoverImages(); @@ -227,7 +223,7 @@ public class CleanupServiceTests : AbstractDbTest // Delete all Series to reset state await ResetDb(); - _context.Users.Add(new AppUser() + Context.Users.Add(new AppUser() { UserName = "Joe", ReadingLists = new List() @@ -243,9 +239,9 @@ public class CleanupServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.DeleteReadingListCoverImages(); @@ -264,7 +260,7 @@ public class CleanupServiceTests : AbstractDbTest filesystem.AddFile($"{CacheDirectory}02.jpg", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); cleanupService.CleanupCacheAndTempDirectories(); Assert.Empty(ds.GetFiles(CacheDirectory, searchOption: SearchOption.AllDirectories)); @@ -278,7 +274,7 @@ public class CleanupServiceTests : AbstractDbTest filesystem.AddFile($"{CacheDirectory}subdir/02.jpg", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); cleanupService.CleanupCacheAndTempDirectories(); Assert.Empty(ds.GetFiles(CacheDirectory, searchOption: SearchOption.AllDirectories)); @@ -301,7 +297,7 @@ public class CleanupServiceTests : AbstractDbTest filesystem.AddFile($"{BackupDirectory}randomfile.zip", filesystemFile); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.CleanupBackups(); Assert.Single(ds.GetFiles(BackupDirectory, searchOption: SearchOption.AllDirectories)); @@ -323,7 +319,7 @@ public class CleanupServiceTests : AbstractDbTest }); var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.CleanupBackups(); Assert.True(filesystem.File.Exists($"{BackupDirectory}randomfile.zip")); @@ -347,7 +343,7 @@ public class CleanupServiceTests : AbstractDbTest } var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.CleanupLogs(); Assert.Single(ds.GetFiles(LogDirectory, searchOption: SearchOption.AllDirectories)); @@ -376,7 +372,7 @@ public class CleanupServiceTests : AbstractDbTest var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + var cleanupService = new CleanupService(_logger, UnitOfWork, _messageHub, ds); await cleanupService.CleanupLogs(); Assert.True(filesystem.File.Exists($"{LogDirectory}kavita20200911.log")); @@ -400,36 +396,36 @@ public class CleanupServiceTests : AbstractDbTest .Build(); series.Library = new LibraryBuilder("Test LIb").Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await _readerService.MarkChaptersUntilAsRead(user, 1, 5); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Validate correct chapters have read status - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); - var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + var cleanupService = new CleanupService(Substitute.For>(), UnitOfWork, Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem())); // Delete the Chapter - _context.Chapter.Remove(c); - await _unitOfWork.CommitAsync(); - Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); + Context.Chapter.Remove(c); + await UnitOfWork.CommitAsync(); + Assert.Empty(await UnitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); // NOTE: This may not be needed, the underlying DB structure seems fixed as of v0.7 await cleanupService.CleanupDbEntries(); - Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); + Assert.Empty(await UnitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); } [Fact] @@ -440,7 +436,7 @@ public class CleanupServiceTests : AbstractDbTest .WithMetadata(new SeriesMetadataBuilder().Build()) .Build(); s.Library = new LibraryBuilder("Test LIb").Build(); - _context.Series.Add(s); + Context.Series.Add(s); var c = new AppUserCollection() { @@ -450,24 +446,24 @@ public class CleanupServiceTests : AbstractDbTest Items = new List() {s} }; - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007", Collections = new List() {c} }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + var cleanupService = new CleanupService(Substitute.For>(), UnitOfWork, Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem())); // Delete the Chapter - _context.Series.Remove(s); - await _unitOfWork.CommitAsync(); + Context.Series.Remove(s); + await UnitOfWork.CommitAsync(); await cleanupService.CleanupDbEntries(); - Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()); + Assert.Empty(await UnitOfWork.CollectionTagRepository.GetAllCollectionsAsync()); } #endregion @@ -484,15 +480,15 @@ public class CleanupServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test LIb").Build(); - _context.Series.Add(s); + Context.Series.Add(s); var user = new AppUser() { UserName = "CleanupWantToRead_ShouldRemoveFullyReadSeries", }; - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); // Add want to read user.WantToRead = new List() @@ -502,12 +498,12 @@ public class CleanupServiceTests : AbstractDbTest SeriesId = s.Id } }; - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); await _readerService.MarkSeriesAsRead(user, s.Id); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); - var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + var cleanupService = new CleanupService(Substitute.For>(), UnitOfWork, Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem())); @@ -515,12 +511,77 @@ public class CleanupServiceTests : AbstractDbTest await cleanupService.CleanupWantToRead(); var wantToRead = - await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, new UserParams(), new FilterDto()); + await UnitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, new UserParams(), new FilterDto()); Assert.Equal(0, wantToRead.TotalCount); } #endregion + #region ConsolidateProgress + + [Fact] + public async Task ConsolidateProgress_ShouldRemoveDuplicates() + { + await ResetDb(); + + var s = new SeriesBuilder("Test ConsolidateProgress_ShouldRemoveDuplicates") + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPages(3) + .Build()) + .Build()) + .Build(); + + s.Library = new LibraryBuilder("Test Lib").Build(); + Context.Series.Add(s); + + var user = new AppUser() + { + UserName = "ConsolidateProgress_ShouldRemoveDuplicates", + }; + Context.AppUser.Add(user); + + await UnitOfWork.CommitAsync(); + + // Add 2 progress events + user.Progresses ??= []; + user.Progresses.Add(new AppUserProgress() + { + ChapterId = 1, + VolumeId = 1, + SeriesId = 1, + LibraryId = s.LibraryId, + PagesRead = 1, + }); + await UnitOfWork.CommitAsync(); + + // Add a duplicate with higher page number + user.Progresses.Add(new AppUserProgress() + { + ChapterId = 1, + VolumeId = 1, + SeriesId = 1, + LibraryId = s.LibraryId, + PagesRead = 3, + }); + await UnitOfWork.CommitAsync(); + + Assert.Equal(2, (await UnitOfWork.AppUserProgressRepository.GetAllProgress()).Count()); + + var cleanupService = new CleanupService(Substitute.For>(), UnitOfWork, + Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); + + + await cleanupService.ConsolidateProgress(); + + var progress = await UnitOfWork.AppUserProgressRepository.GetAllProgress(); + + Assert.Single(progress); + Assert.True(progress.First().PagesRead == 3); + } + #endregion + #region EnsureChapterProgressIsCapped @@ -540,54 +601,54 @@ public class CleanupServiceTests : AbstractDbTest { new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume).WithChapter(c).Build() }; - _context.Series.Add(s); + Context.Series.Add(s); var user = new AppUser() { UserName = "EnsureChapterProgressIsCapped", Progresses = new List() }; - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); await _readerService.MarkChaptersAsRead(user, s.Id, new List() {c}); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); - var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); - await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); + var chapter = await UnitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); + await UnitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); Assert.NotNull(chapter); Assert.Equal(2, chapter.PagesRead); // Update chapter to have 1 page c.Pages = 1; - _unitOfWork.ChapterRepository.Update(c); - await _unitOfWork.CommitAsync(); + UnitOfWork.ChapterRepository.Update(c); + await UnitOfWork.CommitAsync(); - chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); - await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); + chapter = await UnitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); + await UnitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); Assert.NotNull(chapter); Assert.Equal(2, chapter.PagesRead); Assert.Equal(1, chapter.Pages); - var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + var cleanupService = new CleanupService(Substitute.For>(), UnitOfWork, Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem())); await cleanupService.EnsureChapterProgressIsCapped(); - chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); - await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); + chapter = await UnitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); + await UnitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); Assert.NotNull(chapter); Assert.Equal(1, chapter.PagesRead); - _context.AppUser.Remove(user); - await _unitOfWork.CommitAsync(); + Context.AppUser.Remove(user); + await UnitOfWork.CommitAsync(); } #endregion - // #region CleanupBookmarks + #region CleanupBookmarks // // [Fact] // public async Task CleanupBookmarks_LeaveAllFiles() @@ -724,5 +785,5 @@ public class CleanupServiceTests : AbstractDbTest // Assert.Equal(1, ds.FileSystem.Directory.GetDirectories($"{BookmarkDirectory}1/1/").Length); // } // - // #endregion + #endregion } diff --git a/API.Tests/Services/CollectionTagServiceTests.cs b/API.Tests/Services/CollectionTagServiceTests.cs index 85e8391fe..3414dd86b 100644 --- a/API.Tests/Services/CollectionTagServiceTests.cs +++ b/API.Tests/Services/CollectionTagServiceTests.cs @@ -1,6 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs.Collection; @@ -10,6 +12,7 @@ using API.Helpers.Builders; using API.Services; using API.Services.Plus; using API.SignalR; +using Kavita.Common; using NSubstitute; using Xunit; @@ -20,24 +23,24 @@ public class CollectionTagServiceTests : AbstractDbTest private readonly ICollectionTagService _service; public CollectionTagServiceTests() { - _service = new CollectionTagService(_unitOfWork, Substitute.For()); + _service = new CollectionTagService(UnitOfWork, Substitute.For()); } protected override async Task ResetDb() { - _context.AppUserCollection.RemoveRange(_context.AppUserCollection.ToList()); - _context.Library.RemoveRange(_context.Library.ToList()); + Context.AppUserCollection.RemoveRange(Context.AppUserCollection.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); } private async Task SeedSeries() { - if (_context.AppUserCollection.Any()) return; + if (Context.AppUserCollection.Any()) return; var s1 = new SeriesBuilder("Series 1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Mature).Build()).Build(); var s2 = new SeriesBuilder("Series 2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.G).Build()).Build(); - _context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga) + Context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga) .WithSeries(s1) .WithSeries(s2) .Build()); @@ -48,11 +51,69 @@ public class CollectionTagServiceTests : AbstractDbTest new AppUserCollectionBuilder("Tag 1").WithItems(new []{s1}).Build(), new AppUserCollectionBuilder("Tag 2").WithItems(new []{s1, s2}).WithIsPromoted(true).Build() }; - _unitOfWork.UserRepository.Add(user); + UnitOfWork.UserRepository.Add(user); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); } + #region DeleteTag + + [Fact] + public async Task DeleteTag_ShouldDeleteTag_WhenTagExists() + { + // Arrange + await SeedSeries(); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act + var result = await _service.DeleteTag(1, user); + + // Assert + Assert.True(result); + var deletedTag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.Null(deletedTag); + Assert.Single(user.Collections); // Only one collection should remain + } + + [Fact] + public async Task DeleteTag_ShouldReturnTrue_WhenTagDoesNotExist() + { + // Arrange + await SeedSeries(); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act - Try to delete a non-existent tag + var result = await _service.DeleteTag(999, user); + + // Assert + Assert.True(result); // Should return true because the tag is already "deleted" + Assert.Equal(2, user.Collections.Count); // Both collections should remain + } + + [Fact] + public async Task DeleteTag_ShouldNotAffectOtherTags() + { + // Arrange + await SeedSeries(); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act + var result = await _service.DeleteTag(1, user); + + // Assert + Assert.True(result); + var remainingTag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(remainingTag); + Assert.Equal("Tag 2", remainingTag.Title); + Assert.True(remainingTag.Promoted); + } + + #endregion + #region UpdateTag [Fact] @@ -60,12 +121,12 @@ public class CollectionTagServiceTests : AbstractDbTest { await SeedSeries(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.NotNull(user); user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldUpdateFields").WithIsPromoted(true).Build()); - _unitOfWork.UserRepository.Update(user); - await _unitOfWork.CommitAsync(); + UnitOfWork.UserRepository.Update(user); + await UnitOfWork.CommitAsync(); await _service.UpdateTag(new AppUserCollectionDto() { @@ -76,7 +137,7 @@ public class CollectionTagServiceTests : AbstractDbTest AgeRating = AgeRating.Unknown }, 1); - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3); + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(3); Assert.NotNull(tag); Assert.True(tag.Promoted); Assert.False(string.IsNullOrEmpty(tag.Summary)); @@ -90,12 +151,12 @@ public class CollectionTagServiceTests : AbstractDbTest { await SeedSeries(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.NotNull(user); user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource").WithSource(ScrobbleProvider.Mal).Build()); - _unitOfWork.UserRepository.Update(user); - await _unitOfWork.CommitAsync(); + UnitOfWork.UserRepository.Update(user); + await UnitOfWork.CommitAsync(); await _service.UpdateTag(new AppUserCollectionDto() { @@ -106,11 +167,194 @@ public class CollectionTagServiceTests : AbstractDbTest AgeRating = AgeRating.Unknown }, 1); - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3); + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(3); Assert.NotNull(tag); Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title); Assert.False(string.IsNullOrEmpty(tag.Summary)); } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTagDoesNotExist() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Non-existent Tag", + Id = 999, // Non-existent ID + Promoted = false + }, 1)); + + Assert.Equal("collection-doesnt-exist", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenUserDoesNotOwnTag() + { + // Arrange + await SeedSeries(); + + // Create a second user + var user2 = new AppUserBuilder("user2", "user2", Seed.DefaultThemes.First()).Build(); + UnitOfWork.UserRepository.Add(user2); + await UnitOfWork.CommitAsync(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, // This belongs to user1 + Promoted = false + }, 2)); // User with ID 2 + + Assert.Equal("access-denied", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTitleIsEmpty() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = " ", // Empty after trimming + Id = 1, + Promoted = false + }, 1)); + + Assert.Equal("collection-tag-title-required", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTitleAlreadyExists() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 2", // Already exists + Id = 1, // Trying to rename Tag 1 to Tag 2 + Promoted = false + }, 1)); + + Assert.Equal("collection-tag-duplicate", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldUpdateCoverImageSettings() + { + // Arrange + await SeedSeries(); + + // Act + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + CoverImageLocked = true + }, 1); + + // Assert + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.CoverImageLocked); + + // Now test unlocking the cover image + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + CoverImageLocked = false + }, 1); + + tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.False(tag.CoverImageLocked); + Assert.Equal(string.Empty, tag.CoverImage); + } + + [Fact] + public async Task UpdateTag_ShouldAllowPromoteForAdminRole() + { + // Arrange + await SeedSeries(); + + // Setup a user with admin role + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + await AddUserWithRole(user.Id, PolicyConstants.AdminRole); + + + // Act - Try to promote a tag that wasn't previously promoted + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.Promoted); + } + + [Fact] + public async Task UpdateTag_ShouldAllowPromoteForPromoteRole() + { + // Arrange + await SeedSeries(); + + // Setup a user with promote role + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Mock to return promote role for the user + await AddUserWithRole(user.Id, PolicyConstants.PromoteRole); + + // Act - Try to promote a tag that wasn't previously promoted + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.Promoted); + } + + [Fact] + public async Task UpdateTag_ShouldNotChangePromotion_WhenUserHasNoPermission() + { + // Arrange + await SeedSeries(); + + // Setup a user with no special roles + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act - Try to promote a tag without proper role + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.False(tag.Promoted); // Should remain unpromoted + } #endregion @@ -121,17 +365,17 @@ public class CollectionTagServiceTests : AbstractDbTest { await SeedSeries(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.NotNull(user); // Tag 2 has 2 series - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2); Assert.NotNull(tag); await _service.RemoveTagFromSeries(tag, new[] {1}); - var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var userCollections = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.Equal(2, userCollections!.Collections.Count); - Assert.Equal(1, tag.Items.Count); + Assert.Single(tag.Items); Assert.Equal(2, tag.Items.First().Id); } @@ -143,11 +387,11 @@ public class CollectionTagServiceTests : AbstractDbTest { await SeedSeries(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.NotNull(user); // Tag 2 has 2 series - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2); Assert.NotNull(tag); await _service.RemoveTagFromSeries(tag, new[] {1}); @@ -163,18 +407,123 @@ public class CollectionTagServiceTests : AbstractDbTest { await SeedSeries(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); Assert.NotNull(user); // Tag 1 has 1 series - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); Assert.NotNull(tag); await _service.RemoveTagFromSeries(tag, new[] {1}); - var tag2 = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + var tag2 = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); Assert.Null(tag2); } + [Fact] + public async Task RemoveTagFromSeries_ShouldReturnFalse_WhenTagIsNull() + { + // Act + var result = await _service.RemoveTagFromSeries(null, [1]); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleEmptySeriesIdsList() + { + // Arrange + await SeedSeries(); + + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + var initialItemCount = tag.Items.Count; + + // Act + var result = await _service.RemoveTagFromSeries(tag, Array.Empty()); + + // Assert + Assert.True(result); + tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.Equal(initialItemCount, tag.Items.Count); // No items should be removed + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleNonExistentSeriesIds() + { + // Arrange + await SeedSeries(); + + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + var initialItemCount = tag.Items.Count; + + // Act - Try to remove a series that doesn't exist in the tag + var result = await _service.RemoveTagFromSeries(tag, [999]); + + // Assert + Assert.True(result); + tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.Equal(initialItemCount, tag.Items.Count); // No items should be removed + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleNullItemsList() + { + // Arrange + await SeedSeries(); + + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + + // Force null items list + tag.Items = null; + UnitOfWork.CollectionTagRepository.Update(tag); + await UnitOfWork.CommitAsync(); + + // Act + var result = await _service.RemoveTagFromSeries(tag, [1]); + + // Assert + Assert.True(result); + // The tag should not be removed since the items list was null, not empty + var tagAfter = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.Null(tagAfter); + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldUpdateAgeRating_WhenMultipleSeriesRemain() + { + // Arrange + await SeedSeries(); + + // Add a third series with a different age rating + var s3 = new SeriesBuilder("Series 3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.PG).Build()).Build(); + Context.Library.First().Series.Add(s3); + await UnitOfWork.CommitAsync(); + + // Add series 3 to tag 2 + var tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); + tag.Items.Add(s3); + UnitOfWork.CollectionTagRepository.Update(tag); + await UnitOfWork.CommitAsync(); + + // Act - Remove the series with Mature rating + await _service.RemoveTagFromSeries(tag, new[] {1}); + + // Assert + tag = await UnitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); + Assert.Equal(2, tag.Items.Count); + + // The age rating should be updated to the highest remaining rating (PG) + Assert.Equal(AgeRating.PG, tag.AgeRating); + } + + #endregion } diff --git a/API.Tests/Services/CoverDbServiceTests.cs b/API.Tests/Services/CoverDbServiceTests.cs new file mode 100644 index 000000000..93217c3b5 --- /dev/null +++ b/API.Tests/Services/CoverDbServiceTests.cs @@ -0,0 +1,117 @@ +using System.IO; +using System.IO.Abstractions; +using System.Reflection; +using System.Threading.Tasks; +using API.Constants; +using API.Entities.Enums; +using API.Extensions; +using API.Services; +using API.Services.Tasks.Metadata; +using API.SignalR; +using EasyCaching.Core; +using Kavita.Common; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class CoverDbServiceTests : AbstractDbTest +{ + private readonly DirectoryService _directoryService; + private readonly IEasyCachingProviderFactory _cacheFactory = Substitute.For(); + private readonly ICoverDbService _coverDbService; + + private static readonly string FaviconPath = Path.Join(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/CoverDbService/Favicons"); + /// + /// Path to download files temp to. Should be empty after each test. + /// + private static readonly string TempPath = Path.Join(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/CoverDbService/Temp"); + + public CoverDbServiceTests() + { + _directoryService = new DirectoryService(Substitute.For>(), CreateFileSystem()); + var imageService = new ImageService(Substitute.For>(), _directoryService); + + _coverDbService = new CoverDbService(Substitute.For>(), _directoryService, _cacheFactory, + Substitute.For(), imageService, UnitOfWork, Substitute.For()); + } + + protected override Task ResetDb() + { + throw new System.NotImplementedException(); + } + + + #region Download Favicon + + /// + /// I cannot figure out how to test this code due to the reliance on the _directoryService.FaviconDirectory and not being + /// able to redirect it to the real filesystem. + /// + public async Task DownloadFaviconAsync_ShouldDownloadAndMatchExpectedFavicon() + { + // Arrange + var testUrl = "https://anilist.co/anime/6205/Kmpfer/"; + var encodeFormat = EncodeFormat.WEBP; + var expectedFaviconPath = Path.Combine(FaviconPath, "anilist.co.webp"); + + // Ensure TempPath exists + _directoryService.ExistOrCreate(TempPath); + + var baseUrl = "https://anilist.co"; + + // Ensure there is no cache result for this URL + var provider = Substitute.For(); + provider.GetAsync(baseUrl).Returns(new CacheValue(null, false)); + _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon).Returns(provider); + + + // // Replace favicon directory with TempPath + // var directoryService = (DirectoryService)_directoryService; + // directoryService.FaviconDirectory = TempPath; + + // Hack: Swap FaviconDirectory with TempPath for ability to download real files + typeof(DirectoryService) + .GetField("FaviconDirectory", BindingFlags.NonPublic | BindingFlags.Instance) + ?.SetValue(_directoryService, TempPath); + + + // Act + var resultFilename = await _coverDbService.DownloadFaviconAsync(testUrl, encodeFormat); + var actualFaviconPath = Path.Combine(TempPath, resultFilename); + + // Assert file exists + Assert.True(File.Exists(actualFaviconPath), "Downloaded favicon does not exist in temp path"); + + // Load and compare similarity + + var similarity = expectedFaviconPath.CalculateSimilarity(actualFaviconPath); // Assuming you have this extension + Assert.True(similarity > 0.9f, $"Image similarity too low: {similarity}"); + } + + [Fact] + public async Task DownloadFaviconAsync_ShouldThrowKavitaException_WhenPreviouslyFailedUrlExistsInCache() + { + // Arrange + var testUrl = "https://example.com"; + var encodeFormat = EncodeFormat.WEBP; + + var provider = Substitute.For(); + provider.GetAsync(Arg.Any()) + .Returns(new CacheValue(string.Empty, true)); // Simulate previous failure + + _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon).Returns(provider); + + // Act & Assert + await Assert.ThrowsAsync(() => + _coverDbService.DownloadFaviconAsync(testUrl, encodeFormat)); + } + + #endregion + + +} diff --git a/API.Tests/Services/DeviceServiceTests.cs b/API.Tests/Services/DeviceServiceTests.cs index 1d021c76d..cbcf70f82 100644 --- a/API.Tests/Services/DeviceServiceTests.cs +++ b/API.Tests/Services/DeviceServiceTests.cs @@ -18,13 +18,13 @@ public class DeviceServiceDbTests : AbstractDbTest public DeviceServiceDbTests() : base() { - _deviceService = new DeviceService(_unitOfWork, _logger, Substitute.For()); + _deviceService = new DeviceService(UnitOfWork, _logger, Substitute.For()); } protected override async Task ResetDb() { - _context.Users.RemoveRange(_context.Users.ToList()); - await _unitOfWork.CommitAsync(); + Context.Users.RemoveRange(Context.Users.ToList()); + await UnitOfWork.CommitAsync(); } @@ -39,8 +39,8 @@ public class DeviceServiceDbTests : AbstractDbTest Devices = new List() }; - _context.Users.Add(user); - await _unitOfWork.CommitAsync(); + Context.Users.Add(user); + await UnitOfWork.CommitAsync(); var device = await _deviceService.Create(new CreateDeviceDto() { @@ -62,8 +62,8 @@ public class DeviceServiceDbTests : AbstractDbTest Devices = new List() }; - _context.Users.Add(user); - await _unitOfWork.CommitAsync(); + Context.Users.Add(user); + await UnitOfWork.CommitAsync(); var device = await _deviceService.Create(new CreateDeviceDto() { diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 737779f0f..c5216bebf 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.IO.Abstractions.TestingHelpers; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; using API.Services; @@ -10,12 +12,19 @@ using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Xunit.Abstractions; namespace API.Tests.Services; -public class DirectoryServiceTests +public class DirectoryServiceTests: AbstractFsTest { private readonly ILogger _logger = Substitute.For>(); + private readonly ITestOutputHelper _testOutputHelper; + + public DirectoryServiceTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } #region TraverseTreeParallelForEach @@ -373,9 +382,16 @@ public class DirectoryServiceTests #endregion #region IsDriveMounted + // The root directory (/) is always mounted on non windows [Fact] public void IsDriveMounted_DriveIsNotMounted() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _testOutputHelper.WriteLine("Skipping test on non Windows platform"); + return; + } + const string testDirectory = "c:/manga/"; var fileSystem = new MockFileSystem(); fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); @@ -387,6 +403,12 @@ public class DirectoryServiceTests [Fact] public void IsDriveMounted_DriveIsMounted() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _testOutputHelper.WriteLine("Skipping test on non Windows platform"); + return; + } + const string testDirectory = "c:/manga/"; var fileSystem = new MockFileSystem(); fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); @@ -900,12 +922,14 @@ public class DirectoryServiceTests #region GetHumanReadableBytes [Theory] - [InlineData(1200, "1.17 KB")] - [InlineData(1, "1 B")] - [InlineData(10000000, "9.54 MB")] - [InlineData(10000000000, "9.31 GB")] - public void GetHumanReadableBytesTest(long bytes, string expected) + [InlineData(1200, 1.17, " KB")] + [InlineData(1, 1, " B")] + [InlineData(10000000, 9.54, " MB")] + [InlineData(10000000000, 9.31, " GB")] + public void GetHumanReadableBytesTest(long bytes, float number, string suffix) { + // GetHumanReadableBytes is user facing, should be in CultureInfo.CurrentCulture + var expected = number.ToString(CultureInfo.CurrentCulture) + suffix; Assert.Equal(expected, DirectoryService.GetHumanReadableBytes(bytes)); } #endregion @@ -1041,11 +1065,14 @@ public class DirectoryServiceTests #region GetParentDirectory [Theory] - [InlineData(@"C:/file.txt", "C:/")] - [InlineData(@"C:/folder/file.txt", "C:/folder")] - [InlineData(@"C:/folder/subfolder/file.txt", "C:/folder/subfolder")] + [InlineData(@"file.txt", "")] + [InlineData(@"folder/file.txt", "folder")] + [InlineData(@"folder/subfolder/file.txt", "folder/subfolder")] public void GetParentDirectoryName_ShouldFindParentOfFiles(string path, string expected) { + path = Root + path; + expected = Root + expected; + var fileSystem = new MockFileSystem(new Dictionary { { path, new MockFileData(string.Empty)} @@ -1055,11 +1082,14 @@ public class DirectoryServiceTests Assert.Equal(expected, ds.GetParentDirectoryName(path)); } [Theory] - [InlineData(@"C:/folder", "C:/")] - [InlineData(@"C:/folder/subfolder", "C:/folder")] - [InlineData(@"C:/folder/subfolder/another", "C:/folder/subfolder")] + [InlineData(@"folder", "")] + [InlineData(@"folder/subfolder", "folder")] + [InlineData(@"folder/subfolder/another", "folder/subfolder")] public void GetParentDirectoryName_ShouldFindParentOfDirectories(string path, string expected) { + path = Root + path; + expected = Root + expected; + var fileSystem = new MockFileSystem(); fileSystem.AddDirectory(path); diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs new file mode 100644 index 000000000..973b7c6df --- /dev/null +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -0,0 +1,3198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; +using API.DTOs.Scrobbling; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; +using API.Helpers.Builders; +using API.Services.Plus; +using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner.Parser; +using API.SignalR; +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +/// +/// Given these rely on Kavita+, this will not have any [Fact]/[Theory] on them and must be manually checked +/// +public class ExternalMetadataServiceTests : AbstractDbTest +{ + private readonly ExternalMetadataService _externalMetadataService; + private readonly Dictionary _genreLookup = new Dictionary(); + private readonly Dictionary _tagLookup = new Dictionary(); + private readonly Dictionary _personLookup = new Dictionary(); + + + public ExternalMetadataServiceTests() + { + // Set up Hangfire to use in-memory storage for testing + GlobalConfiguration.Configuration.UseInMemoryStorage(); + + _externalMetadataService = new ExternalMetadataService(UnitOfWork, Substitute.For>(), + Mapper, Substitute.For(), Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); + } + + #region Gloabl + + [Fact] + public async Task Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = false; + metadataSettings.EnableSummary = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.Metadata.Summary); + } + + #endregion + + #region Summary + + [Fact] + public async Task Summary_NoExisting_Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal(series.Metadata.Summary, postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked") + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should not write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This summary is not locked", postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked", true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should not write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This summary is not locked", postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked", true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + metadataSettings.Overrides = [MetadataSettingField.Summary]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This should write", postSeries.Metadata.Summary); + } + + + #endregion + + #region Release Year + + [Fact] + public async Task ReleaseYear_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(0, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(DateTime.UtcNow.Year, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(1990, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990, true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(1990, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990, true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + metadataSettings.Overrides = [MetadataSettingField.StartDate]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(DateTime.UtcNow.Year, postSeries.Metadata.ReleaseYear); + } + + #endregion + + #region LocalizedName + + [Fact] + public async Task LocalizedName_NoExisting_Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Kimchi", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here") + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Localized Name here", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here", true) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Localized Name here", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here", true) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + metadataSettings.Overrides = [MetadataSettingField.LocalizedName]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Kimchi", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_OnlyNonEnglishSynonyms_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.True(string.IsNullOrEmpty(postSeries.LocalizedName)); + } + + #endregion + + #region Publication Status + + [Fact] + public async Task PublicationStatus_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.OnGoing, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus, true) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Hiatus, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus, true) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + metadataSettings.Overrides = [MetadataSettingField.PublicationStatus]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_CorrectState_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Ended, postSeries.Metadata.PublicationStatus); + } + + + [Fact] + public void IsSeriesCompleted_ExactMatch() + { + const string seriesName = "Test - Exact Match"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(5) + .WithTotalCount(5) + .Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 5, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + } + + [Fact] + public void IsSeriesCompleted_Volumes_DecimalVolumes() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder("2.5").WithNumber(2.5f).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes decimal volume 2.5 + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.True(result); + Assert.Equal(3, series.Metadata.MaxCount); + Assert.Equal(3, series.Metadata.TotalCount); + } + + /// + /// This is validating that we get a completed even though we have a special chapter and AL doesn't count it + /// + [Fact] + public void IsSeriesCompleted_Volumes_HasSpecialAndDecimal_ExternalNoSpecial() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("1.5").WithNumber(1.5f).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes volume 1.5, but not the special + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.True(result); + Assert.Equal(3, series.Metadata.MaxCount); + Assert.Equal(3, series.Metadata.TotalCount); + } + + /// + /// This unit test also illustrates the bug where you may get a false positive if you had Volumes 1,2, and 2.1. While + /// missing volume 3. With the external metadata expecting non-decimal volumes. + /// i.e. it would fail if we only had one decimal volume + /// + [Fact] + public void IsSeriesCompleted_Volumes_TooManyDecimalVolumes() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder("2.1").WithNumber(2.1f).Build()) + .WithVolume(new VolumeBuilder("2.2").WithNumber(2.2f).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes no special or decimals. There are 3 volumes. And we're missing volume 3 + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.False(result); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_GEQChapterCheck() + { + // We own 11 chapters, the external metadata expects 10 + const string seriesName = "Test - Chapter MaxCount, no volumes"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(11) + .WithTotalCount(10) + .Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + Assert.Equal(11, series.Metadata.TotalCount); + Assert.Equal(11, series.Metadata.MaxCount); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_IncludeAllChaptersCheck() + { + const string seriesName = "Test - Chapter Count"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(7) + .WithTotalCount(10) + .Build()) + .Build(); + + var chapters = new List + { + new ChapterBuilder("0").Build(), + new ChapterBuilder("2").Build(), + new ChapterBuilder("3").Build(), + new ChapterBuilder("4").Build(), + new ChapterBuilder("5").Build(), + new ChapterBuilder("6").Build(), + new ChapterBuilder("7").Build(), + new ChapterBuilder("7.1").Build(), + new ChapterBuilder("7.2").Build(), + new ChapterBuilder("7.3").Build() + }; + // External metadata includes prologues (0) and extra's (7.X) + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + Assert.Equal(10, series.Metadata.TotalCount); + Assert.Equal(10, series.Metadata.MaxCount); + } + + [Fact] + public void IsSeriesCompleted_NotEnoughVolumes() + { + const string seriesName = "Test - Incomplete Volume"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(5) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 5 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.False(result); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_NotEnoughChapters() + { + const string seriesName = "Test - Incomplete Chapter"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(5) + .WithTotalCount(8) + .Build()) + .Build(); + + var chapters = new List + { + new ChapterBuilder("1").Build(), + new ChapterBuilder("2").Build(), + new ChapterBuilder("3").Build() + }; + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.False(result); + } + + + #endregion + + #region Age Rating + + [Fact] + public async Task AgeRating_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_ExistingHigher_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Mature) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Mature, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_ExistingLower_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone, true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Everyone, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone, true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.Overrides = [MetadataSettingField.AgeRating]; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + #endregion + + #region Genres + + [Fact] + public async Task Genres_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.Genres); + } + + [Fact] + public async Task Genres_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"]) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"], true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Action"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"], true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + #endregion + + #region Tags + + [Fact] + public async Task Tags_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.Tags); + } + + [Fact] + public async Task Tags_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task Tags_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithTag(_tagLookup["H"], true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["H"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task Tags_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithTag(_tagLookup["H"], true) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Tags]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + #endregion + + #region People - Writers/Artists + + [Fact] + public async Task People_Writer_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer)); + } + + [Fact] + public async Task People_Writer_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["John Doe"], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Person.Name)); + } + + [Fact] + public async Task People_Writer_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Writer_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe", "Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + Assert.True( postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .FirstOrDefault(p => p.Person.Name == "John Doe")!.KavitaPlusConnection); + } + + [Fact] + public async Task People_Writer_Locked_Override_ReverseNamingMatch_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = false; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Twowheeler", "Johnny", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Writer_Locked_Override_PersonRoleNotSet_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = []; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + + [Fact] + public async Task People_Writer_OverrideReMatchDeletesOld_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe 2", "Story")] + }, 1); + + postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe 2"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + #endregion + + #region People Alias + + [Fact] + public async Task PeopleAliasing_AddAsAlias() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + Context.Person.Add(new PersonBuilder("John Doe").Build()); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Doe", "John", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Single(allWriters); + + var johnDoe = allWriters[0].Person; + + Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias)); + } + + [Fact] + public async Task PeopleAliasing_AddOnAlias() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + Context.Person.Add(new PersonBuilder("John Doe").WithAlias("Doe John").Build()); + + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Doe", "John", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Single(allWriters); + + var johnDoe = allWriters[0].Person; + + Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias)); + } + + [Fact] + public async Task PeopleAliasing_DontAddAsAlias_SameButNotSwitched() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe Doe", "Story"), CreateStaff("Doe", "John Doe", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Equal(2, allWriters.Count); + } + + #endregion + + #region People - Characters + + [Fact] + public async Task People_Character_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = false; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character)); + } + + [Fact] + public async Task People_Character_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["John Doe"], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character).Select(p => p.Person.Name)); + } + + [Fact] + public async Task People_Character_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.CharacterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Character_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe", "Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + Assert.True( postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .FirstOrDefault(p => p.Person.Name == "John Doe")!.KavitaPlusConnection); + } + + [Fact] + public async Task People_Character_Locked_Override_ReverseNamingNoMatch_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = false; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("Twowheeler", "Johnny", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler", "Twowheeler Johnny"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Character_Locked_Override_PersonRoleNotSet_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = []; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + + [Fact] + public async Task People_Character_OverrideReMatchDeletesOld_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe 2", CharacterRole.Main)] + }, 1); + + postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe 2"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + #endregion + + #region Series Cover + // Not sure how to test this + #endregion + + #region Relationships + + // Not enabled + + // Non-Sequel + + [Fact] + public async Task Relationships_NonSequel() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithExternalMetadata(new ExternalSeriesMetadata() + { + AniListId = 10 + }) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + } + + [Fact] + public async Task Relationships_NonSequel_LocalizedName() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithLocalizedName("School bus") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = "School bus", + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + } + + // Non-Sequel with no match due to Format difference + [Fact] + public async Task Relationships_NonSequel_FormatDifference() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithLocalizedName("School bus") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = "School bus", + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Book + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Empty(sourceSeries.Relations); + } + + // Non-Sequel existing relationship with new link, both exist + [Fact] + public async Task Relationships_NonSequel_ExistingLink_DifferentType_BothExist() + { + await ResetDb(); + + var existingRelationshipSeries = new SeriesBuilder("Existing") + .WithLibraryId(1) + .Build(); + Context.Series.Attach(existingRelationshipSeries); + await Context.SaveChangesAsync(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithRelationship(existingRelationshipSeries.Id, RelationKind.Annual) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithExternalMetadata(new ExternalSeriesMetadata() + { + AniListId = 10 + }) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 2); + + // Repull Series and validate what is overwritten + var sourceSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Equal(seriesName, sourceSeries.Name); + + Assert.Contains(sourceSeries.Relations, r => r.RelationKind == RelationKind.Annual && r.TargetSeriesId == existingRelationshipSeries.Id); + Assert.Contains(sourceSeries.Relations, r => r.RelationKind == RelationKind.SideStory && r.TargetSeriesId == series2.Id); + } + + + + // Sequel/Prequel + [Fact] + public async Task Relationships_Sequel_CreatesPrequel() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Source"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.Sequel, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + + var sequel = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sequel); + Assert.Equal(seriesName, sequel.Relations.First().TargetSeries.Name); + } + + [Fact] + public async Task Relationships_Prequel_CreatesSequel() + { + await ResetDb(); + + // ID 1: Blue Lock - Episode Nagi + var series = new SeriesBuilder("Blue Lock - Episode Nagi") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + // ID 2: Blue Lock + var series2 = new SeriesBuilder("Blue Lock") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series2); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + // Apply to Blue Lock - Episode Nagi (ID 1), setting Blue Lock (ID 2) as its prequel + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = "Blue Lock - Episode Nagi", // The series we're updating metadata for + Relations = [new SeriesRelationship() + { + Relation = RelationKind.Prequel, // Blue Lock is the prequel to Nagi + SeriesName = new ALMediaTitle() + { + PreferredTitle = "Blue Lock", + EnglishTitle = "Blue Lock", + NativeTitle = "ブルーロック", + RomajiTitle = "Blue Lock", + }, + PlusMediaFormat = PlusMediaFormat.Manga, + AniListId = 106130, + MalId = 114745, + Provider = ScrobbleProvider.AniList + }] + }, 1); // Apply to series ID 1 (Nagi) + + // Verify Blue Lock - Episode Nagi has Blue Lock as prequel + var nagiSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(nagiSeries); + Assert.Single(nagiSeries.Relations); + Assert.Equal("Blue Lock", nagiSeries.Relations.First().TargetSeries.Name); + Assert.Equal(RelationKind.Prequel, nagiSeries.Relations.First().RelationKind); + + // Verify Blue Lock has Blue Lock - Episode Nagi as sequel + var blueLockSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(blueLockSeries); + Assert.Single(blueLockSeries.Relations); + Assert.Equal("Blue Lock - Episode Nagi", blueLockSeries.Relations.First().TargetSeries.Name); + Assert.Equal(RelationKind.Sequel, blueLockSeries.Relations.First().RelationKind); + } + + + #endregion + + #region Blacklist + + [Fact] + public async Task Blacklist_Genres() + { + await ResetDb(); + + const string seriesName = "Test - Blacklist Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.EnableGenres = true; + metadataSettings.Blacklist = ["Sports", "Action"]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Boxing", "Sports", "Action"], + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Boxing"}.OrderBy(s => s), postSeries.Metadata.Genres.Select(t => t.Title).OrderBy(s => s)); + } + + + [Fact] + public async Task Blacklist_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Blacklist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.EnableGenres = true; + metadataSettings.Blacklist = ["Sports", "Action"]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Sports"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Boxing"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + // Blacklist Tag + + // Field Map then Blacklist Genre + + // Field Map then Blacklist Tag + + #endregion + + #region Whitelist + + [Fact] + public async Task Whitelist_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Whitelist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Whitelist = ["Sports", "Action"]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Sports"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Sports", "Action"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + [Fact] + public async Task Whitelist_WithFieldMap_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Whitelist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Boxing", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Sports", + ExcludeFromSource = false + + }]; + metadataSettings.Whitelist = ["Sports", "Action"]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Sports", "Action"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + #endregion + + #region Field Mapping + + [Fact] + public async Task FieldMap_GenreToGenre_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] { "Ecchi", "Fanservice" }.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + } + + [Fact] + public async Task FieldMap_GenreToGenre_RemoveSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Fanservice", + ExcludeFromSource = true + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Fanservice"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task FieldMap_TagToTag_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tag Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Ecchi"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] { "Ecchi", "Fanservice" }.OrderBy(s => s), + postSeries.Metadata.Tags.Select(g => g.Title).OrderBy(s => s) + ); + } + + [Fact] + public async Task Tags_Existing_FieldMap_RemoveSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tag Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = true + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Ecchi"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Fanservice"], postSeries.Metadata.Tags.Select(g => g.Title)); + } + + [Fact] + public async Task FieldMap_GenreToTag_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres, MetadataSettingField.Tags]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] {"Ecchi"}.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + Assert.Equal( + new[] {"Fanservice"}.OrderBy(s => s), + postSeries.Metadata.Tags.Select(g => g.Title).OrderBy(s => s) + ); + } + + + + [Fact] + public async Task FieldMap_GenreToGenre_RemoveSource_NoExternalGenre_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"]) + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres, MetadataSettingField.Tags]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Action", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Adventure", + ExcludeFromSource = true + + }]; + + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] {"Action"}.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + } + + #endregion + + + + protected override async Task ResetDb() + { + Context.Series.RemoveRange(Context.Series); + Context.AppUser.RemoveRange(Context.AppUser); + Context.Genre.RemoveRange(Context.Genre); + Context.Tag.RemoveRange(Context.Tag); + Context.Person.RemoveRange(Context.Person); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = false; + metadataSettings.EnableSummary = false; + metadataSettings.EnableCoverImage = false; + metadataSettings.EnableLocalizedName = false; + metadataSettings.EnableGenres = false; + metadataSettings.EnablePeople = false; + metadataSettings.EnableRelationships = false; + metadataSettings.EnableTags = false; + metadataSettings.EnablePublicationStatus = false; + metadataSettings.EnableStartDate = false; + metadataSettings.FieldMappings = []; + metadataSettings.AgeRatingMappings = new Dictionary(); + Context.MetadataSettings.Update(metadataSettings); + + await Context.SaveChangesAsync(); + + Context.AppUser.Add(new AppUserBuilder("Joe", "Joe") + .WithRole(PolicyConstants.AdminRole) + .WithLibrary(await Context.Library.FirstAsync(l => l.Id == 1)) + .Build()); + + // Create a bunch of Genres for this test and store their string in _genreLookup + _genreLookup.Clear(); + var g1 = new GenreBuilder("Action").Build(); + var g2 = new GenreBuilder("Ecchi").Build(); + Context.Genre.Add(g1); + Context.Genre.Add(g2); + _genreLookup.Add("Action", g1); + _genreLookup.Add("Ecchi", g2); + + _tagLookup.Clear(); + var t1 = new TagBuilder("H").Build(); + var t2 = new TagBuilder("Boxing").Build(); + Context.Tag.Add(t1); + Context.Tag.Add(t2); + _tagLookup.Add("H", t1); + _tagLookup.Add("Boxing", t2); + + _personLookup.Clear(); + var p1 = new PersonBuilder("Johnny Twowheeler").Build(); + var p2 = new PersonBuilder("Boxing").Build(); + Context.Person.Add(p1); + Context.Person.Add(p2); + _personLookup.Add("Johnny Twowheeler", p1); + _personLookup.Add("Batman Robin", p2); + + await Context.SaveChangesAsync(); + } + + private static SeriesStaffDto CreateStaff(string first, string last, string role) + { + return new SeriesStaffDto() {Name = $"{first} {last}", Role = role, Url = "", FirstName = first, LastName = last}; + } + + private static SeriesCharacter CreateCharacter(string first, string last, CharacterRole role) + { + return new SeriesCharacter() {Name = $"{first} {last}", Description = "", Url = "", ImageUrl = "", Role = role}; + } +} diff --git a/API.Tests/Services/ImageServiceTests.cs b/API.Tests/Services/ImageServiceTests.cs index ac3c3157f..f2c87e1ad 100644 --- a/API.Tests/Services/ImageServiceTests.cs +++ b/API.Tests/Services/ImageServiceTests.cs @@ -1,14 +1,9 @@ -using System.Drawing; -using System.IO; -using System.IO.Abstractions; +using System.IO; using System.Linq; using System.Text; using API.Entities.Enums; using API.Services; -using EasyCaching.Core; -using Microsoft.Extensions.Logging; using NetVips; -using NSubstitute; using Xunit; using Image = NetVips.Image; @@ -28,6 +23,7 @@ public class ImageServiceTests public void GenerateBaseline() { GenerateFiles(BaselinePattern); + Assert.True(true); } /// @@ -38,6 +34,7 @@ public class ImageServiceTests { GenerateFiles(OutputPattern); GenerateHtmlFile(); + Assert.True(true); } private void GenerateFiles(string outputExtension) @@ -159,15 +156,15 @@ public class ImageServiceTests // Step 4: Generate HTML file GenerateHtmlFileForColorScape(); - + Assert.True(true); } private static void GenerateColorImage(string hexColor, string outputPath) { - var color = ImageService.HexToRgb(hexColor); - using var colorImage = Image.Black(200, 100); - using var output = colorImage + new[] { color.R / 255.0, color.G / 255.0, color.B / 255.0 }; - output.WriteToFile(outputPath); + var (r, g, b) = ImageService.HexToRgb(hexColor); + using var blackImage = Image.Black(200, 100); + using var colorImage = blackImage.NewFromImage(r, g, b); + colorImage.WriteToFile(outputPath); } private void GenerateHtmlFileForColorScape() diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index ff4868a8c..a732b2526 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -1,27 +1,23 @@ using System; using System.Collections.Generic; -using System.Data.Common; +using System.IO; +using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; -using API.Data; using API.Data.Metadata; using API.Data.Repositories; -using API.Entities; using API.Entities.Enums; -using API.Extensions; -using API.Helpers.Builders; using API.Services; using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner.Parser; using API.SignalR; -using AutoMapper; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; +using API.Tests.Helpers; +using Hangfire; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Xunit.Abstractions; namespace API.Tests.Services; @@ -62,53 +58,55 @@ public class MockReadingItemService : IReadingItemService throw new NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { if (_comicVineParser.IsApplicable(path, type)) { - return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_imageParser.IsApplicable(path, type)) { - return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_bookParser.IsApplicable(path, type)) { - return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_pdfParser.IsApplicable(path, type)) { - return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_basicParser.IsApplicable(path, type)) { - return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } return null; } - public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { - return Parse(path, rootPath, libraryRoot, type); + return Parse(path, rootPath, libraryRoot, type, enableMetadata); } } public class ParseScannedFilesTests : AbstractDbTest { private readonly ILogger _logger = Substitute.For>(); + private readonly ScannerHelper _scannerHelper; - public ParseScannedFilesTests() + public ParseScannedFilesTests(ITestOutputHelper testOutputHelper) { // Since ProcessFile relies on _readingItemService, we can implement our own versions of _readingItemService so we have control over how the calls work - + GlobalConfiguration.Configuration.UseInMemoryStorage(); + _scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper); } protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } #region MergeName @@ -196,11 +194,11 @@ public class ParseScannedFilesTests : AbstractDbTest public async Task ScanLibrariesForSeries_ShouldFindFiles() { var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory("C:/Data/"); - fileSystem.AddFile("C:/Data/Accel World v1.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World v2.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World v2.pdf", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Nothing.pdf", new MockFileData(string.Empty)); + fileSystem.AddDirectory(Root + "Data/"); + fileSystem.AddFile(Root + "Data/Accel World v1.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile(Root + "Data/Accel World v2.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile(Root + "Data/Accel World v2.pdf", new MockFileData(string.Empty)); + fileSystem.AddFile(Root + "Data/Nothing.pdf", new MockFileData(string.Empty)); var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, @@ -208,13 +206,13 @@ public class ParseScannedFilesTests : AbstractDbTest var library = - await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); library.Type = LibraryType.Manga; - var parsedSeries = await psf.ScanLibrariesForSeries(library, new List() {"C:/Data/"}, false, - await _unitOfWork.SeriesRepository.GetFolderPathMap(1)); + var parsedSeries = await psf.ScanLibrariesForSeries(library, new List() {Root + "Data/"}, false, + await UnitOfWork.SeriesRepository.GetFolderPathMap(1)); // Assert.Equal(3, parsedSeries.Values.Count); @@ -253,9 +251,9 @@ public class ParseScannedFilesTests : AbstractDbTest new MockReadingItemService(ds, Substitute.For()), Substitute.For()); var directoriesSeen = new HashSet(); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); - var scanResults = await psf.ScanFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + var scanResults = await psf.ScanFiles("C:/Data/", true, await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library); foreach (var scanResult in scanResults) { directoriesSeen.Add(scanResult.Folder); @@ -272,13 +270,13 @@ public class ParseScannedFilesTests : AbstractDbTest var psf = new ParseScannedFiles(Substitute.For>(), ds, new MockReadingItemService(ds, Substitute.For()), Substitute.For()); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); var directoriesSeen = new HashSet(); var scanResults = await psf.ScanFiles("C:/Data/", false, - await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library); foreach (var scanResult in scanResults) { @@ -307,10 +305,10 @@ public class ParseScannedFilesTests : AbstractDbTest var psf = new ParseScannedFiles(Substitute.For>(), ds, new MockReadingItemService(ds, Substitute.For()), Substitute.For()); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); - var scanResults = await psf.ScanFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + var scanResults = await psf.ScanFiles("C:/Data", true, await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library); Assert.Equal(2, scanResults.Count); } @@ -336,11 +334,11 @@ public class ParseScannedFilesTests : AbstractDbTest var psf = new ParseScannedFiles(Substitute.For>(), ds, new MockReadingItemService(ds, Substitute.For()), Substitute.For()); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); var scanResults = await psf.ScanFiles("C:/Data", false, - await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + await UnitOfWork.SeriesRepository.GetFolderPathMap(1), library); Assert.Single(scanResults); } @@ -349,4 +347,220 @@ public class ParseScannedFilesTests : AbstractDbTest #endregion + + // TODO: Add back in (removed for Hotfix v0.8.5.x) + //[Fact] + public async Task HasSeriesFolderNotChangedSinceLastScan_AllSeriesFoldersHaveChanges() + { + const string testcase = "Subfolders always scanning all series changes - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + + var frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Single(frieren.Volumes); + + var executionerAndHerWayOfLife = postLib.Series.First(x => x.Name == "The Executioner and Her Way of Life"); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count); + + await Task.Delay(1100); // Ensure at least one second has passed since library scan + + // Add a new chapter to a volume of the series, and scan. Validate that only, and all directories of this + // series are marked as HasChanged + var executionerCopyDir = Path.Join(Path.Join(testDirectoryPath, "The Executioner and Her Way of Life"), + "The Executioner and Her Way of Life Vol. 1"); + File.Copy(Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0001.cbz"), + Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0002.cbz")); + + // 4 series, of which 2 have volumes as directories + var folderMap = await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id); + Assert.Equal(6, folderMap.Count); + + var res = await psf.ScanFiles(testDirectoryPath, true, folderMap, postLib); + var changes = res.Where(sc => sc.HasChanged).ToList(); + Assert.Equal(2, changes.Count); + // Only volumes of The Executioner and Her Way of Life should be marked as HasChanged (Spice and Wolf also has 2 volumes dirs) + Assert.Equal(2, changes.Count(sc => sc.Folder.Contains("The Executioner and Her Way of Life"))); + } + + [Fact] + public async Task HasSeriesFolderNotChangedSinceLastScan_PublisherLayout() + { + const string testcase = "Subfolder always scanning fix publisher layout - Comic.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + + var frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Equal(2, frieren.Volumes.Count); + + await Task.Delay(1100); // Ensure at least one second has passed since library scan + + // Add a volume to a series, and scan. Ensure only this series is marked as HasChanged + var executionerCopyDir = Path.Join(Path.Join(testDirectoryPath, "YenPress"), "The Executioner and Her Way of Life"); + File.Copy(Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1.cbz"), + Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 2.cbz")); + + var res = await psf.ScanFiles(testDirectoryPath, true, + await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + var changes = res.Count(sc => sc.HasChanged); + Assert.Equal(1, changes); + } + + // TODO: Add back in (removed for Hotfix v0.8.5.x) + //[Fact] + public async Task SubFoldersNoSubFolders_SkipAll() + { + const string testcase = "Subfolders and files at root - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + // Needs to be actual time as the write time is now, so if we set LastFolderChecked in the past + // it'll always a scan as it was changed since the last scan. + await Task.Delay(1100); // Ensure at least one second has passed since library scan + + var res = await psf.ScanFiles(testDirectoryPath, true, + await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + Assert.DoesNotContain(res, sc => sc.HasChanged); + } + + [Fact] + public async Task SubFoldersNoSubFolders_ScanAllAfterAddInRoot() + { + const string testcase = "Subfolders and files at root - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + spiceAndWolf.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(2)); + Context.Series.Update(spiceAndWolf); + await Context.SaveChangesAsync(); + + // Add file at series root + var spiceAndWolfDir = Path.Join(testDirectoryPath, "Spice and Wolf"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 1.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 4.cbz")); + + var res = await psf.ScanFiles(testDirectoryPath, true, + await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + var changes = res.Count(sc => sc.HasChanged); + Assert.Equal(2, changes); + } + + [Fact] + public async Task SubFoldersNoSubFolders_ScanAllAfterAddInSubFolder() + { + const string testcase = "Subfolders and files at root - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + spiceAndWolf.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(2)); + Context.Series.Update(spiceAndWolf); + await Context.SaveChangesAsync(); + + // Add file in subfolder + var spiceAndWolfDir = Path.Join(Path.Join(testDirectoryPath, "Spice and Wolf"), "Spice and Wolf Vol. 3"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0011.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0013.cbz")); + + var res = await psf.ScanFiles(testDirectoryPath, true, + await UnitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + var changes = res.Count(sc => sc.HasChanged); + Assert.Equal(2, changes); + } } diff --git a/API.Tests/Services/PersonServiceTests.cs b/API.Tests/Services/PersonServiceTests.cs new file mode 100644 index 000000000..5c1929b1c --- /dev/null +++ b/API.Tests/Services/PersonServiceTests.cs @@ -0,0 +1,286 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Extensions; +using API.Helpers.Builders; +using API.Services; +using Xunit; + +namespace API.Tests.Services; + +public class PersonServiceTests: AbstractDbTest +{ + + [Fact] + public async Task PersonMerge_KeepNonEmptyMetadata() + { + var ps = new PersonService(UnitOfWork); + + var person1 = new Person + { + Name = "Casey Delores", + NormalizedName = "Casey Delores".ToNormalized(), + HardcoverId = "ANonEmptyId", + MalId = 12, + }; + + var person2 = new Person + { + Name= "Delores Casey", + NormalizedName = "Delores Casey".ToNormalized(), + Description = "Hi, I'm Delores Casey!", + Aliases = [new PersonAliasBuilder("Casey, Delores").Build()], + AniListId = 27, + }; + + UnitOfWork.PersonRepository.Attach(person1); + UnitOfWork.PersonRepository.Attach(person2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person1); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + var person = allPeople[0]; + Assert.Equal("Casey Delores", person.Name); + Assert.NotEmpty(person.Description); + Assert.Equal(27, person.AniListId); + Assert.NotNull(person.HardcoverId); + Assert.NotEmpty(person.HardcoverId); + Assert.Contains(person.Aliases, pa => pa.Alias == "Delores Casey"); + Assert.Contains(person.Aliases, pa => pa.Alias == "Casey, Delores"); + } + + [Fact] + public async Task PersonMerge_MergedPersonDestruction() + { + var ps = new PersonService(UnitOfWork); + + var person1 = new Person + { + Name = "Casey Delores", + NormalizedName = "Casey Delores".ToNormalized(), + }; + + var person2 = new Person + { + Name = "Delores Casey", + NormalizedName = "Delores Casey".ToNormalized(), + }; + + UnitOfWork.PersonRepository.Attach(person1); + UnitOfWork.PersonRepository.Attach(person2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person1); + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + } + + [Fact] + public async Task PersonMerge_RetentionChapters() + { + var ps = new PersonService(UnitOfWork); + + var library = new LibraryBuilder("My Library").Build(); + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var user = new AppUserBuilder("Amelia", "amelia@localhost") + .WithLibrary(library).Build(); + UnitOfWork.UserRepository.Add(user); + + var person = new PersonBuilder("Jillian Cowan").Build(); + + var person2 = new PersonBuilder("Cowan Jillian").Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .Build(); + + var chapter2 = new ChapterBuilder("2") + .WithPerson(person2, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + var series2 = new SeriesBuilder("Test 2") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("2") + .WithChapter(chapter2) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + UnitOfWork.SeriesRepository.Add(series2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + var mergedPerson = allPeople[0]; + + Assert.Equal("Jillian Cowan", mergedPerson.Name); + + var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(1, 1, PersonRole.Editor); + Assert.Equal(2, chapters.Count()); + + chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(1, ChapterIncludes.People); + Assert.NotNull(chapter); + Assert.Single(chapter.People); + + chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2, ChapterIncludes.People); + Assert.NotNull(chapter2); + Assert.Single(chapter2.People); + + Assert.Equal(chapter.People.First().PersonId, chapter2.People.First().PersonId); + } + + [Fact] + public async Task PersonMerge_NoDuplicateChaptersOrSeries() + { + await ResetDb(); + + var ps = new PersonService(UnitOfWork); + + var library = new LibraryBuilder("My Library").Build(); + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var user = new AppUserBuilder("Amelia", "amelia@localhost") + .WithLibrary(library).Build(); + UnitOfWork.UserRepository.Add(user); + + var person = new PersonBuilder("Jillian Cowan").Build(); + + var person2 = new PersonBuilder("Cowan Jillian").Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Colorist) + .Build(); + + var chapter2 = new ChapterBuilder("2") + .WithPerson(person2, PersonRole.Editor) + .WithPerson(person, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Editor) + .Build()) + .Build(); + + var series2 = new SeriesBuilder("Test 2") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("2") + .WithChapter(chapter2) + .Build()) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Colorist) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + UnitOfWork.SeriesRepository.Add(series2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person); + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + var mergedPerson = await UnitOfWork.PersonRepository.GetPersonById(person.Id, PersonIncludes.All); + Assert.NotNull(mergedPerson); + Assert.Equal(3, mergedPerson.ChapterPeople.Count); + Assert.Equal(3, mergedPerson.SeriesMetadataPeople.Count); + + chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter.Id, ChapterIncludes.People); + Assert.NotNull(chapter); + Assert.Equal(2, chapter.People.Count); + Assert.Single(chapter.People.Select(p => p.Person.Id).Distinct()); + Assert.Contains(chapter.People, p => p.Role == PersonRole.Editor); + Assert.Contains(chapter.People, p => p.Role == PersonRole.Colorist); + + chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter2.Id, ChapterIncludes.People); + Assert.NotNull(chapter2); + Assert.Single(chapter2.People); + Assert.Contains(chapter2.People, p => p.Role == PersonRole.Editor); + Assert.DoesNotContain(chapter2.People, p => p.Role == PersonRole.Colorist); + + series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Metadata); + Assert.NotNull(series); + Assert.Single(series.Metadata.People); + Assert.Contains(series.Metadata.People, p => p.Role == PersonRole.Editor); + Assert.DoesNotContain(series.Metadata.People, p => p.Role == PersonRole.Colorist); + + series2 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series2.Id, SeriesIncludes.Metadata); + Assert.NotNull(series2); + Assert.Equal(2, series2.Metadata.People.Count); + Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Editor); + Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Colorist); + + + } + + [Fact] + public async Task PersonAddAlias_NoOverlap() + { + await ResetDb(); + + UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jillian Cowan").Build()); + UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jilly Cowan").WithAlias("Jolly Cowan").Build()); + await UnitOfWork.CommitAsync(); + + var ps = new PersonService(UnitOfWork); + + var person1 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jillian Cowan"); + var person2 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jilly Cowan"); + Assert.NotNull(person1); + Assert.NotNull(person2); + + // Overlap on Name + var success = await ps.UpdatePersonAliasesAsync(person1, ["Jilly Cowan"]); + Assert.False(success); + + // Overlap on alias + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan"]); + Assert.False(success); + + // No overlap + success = await ps.UpdatePersonAliasesAsync(person2, ["Jilly Joy Cowan"]); + Assert.True(success); + + // Some overlap + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]); + Assert.False(success); + + // Some overlap + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]); + Assert.False(success); + + Assert.Single(person2.Aliases); + } + + protected override async Task ResetDb() + { + Context.Person.RemoveRange(Context.Person.ToList()); + + await Context.SaveChangesAsync(); + } +} diff --git a/API.Tests/Services/ProcessSeriesTests.cs b/API.Tests/Services/ProcessSeriesTests.cs index ef5c45007..119e1bc10 100644 --- a/API.Tests/Services/ProcessSeriesTests.cs +++ b/API.Tests/Services/ProcessSeriesTests.cs @@ -1,23 +1,8 @@ -using System.IO; -using API.Data; -using API.Data.Metadata; -using API.Entities; -using API.Entities.Enums; -using API.Helpers; -using API.Helpers.Builders; -using API.Services; -using API.Services.Tasks.Metadata; -using API.Services.Tasks.Scanner; -using API.SignalR; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Xunit; - -namespace API.Tests.Services; +namespace API.Tests.Services; public class ProcessSeriesTests { - + // TODO: Implement #region UpdateSeriesMetadata diff --git a/API.Tests/Services/RatingServiceTests.cs b/API.Tests/Services/RatingServiceTests.cs new file mode 100644 index 000000000..15f4541d7 --- /dev/null +++ b/API.Tests/Services/RatingServiceTests.cs @@ -0,0 +1,189 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.DTOs; +using API.Entities.Enums; +using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; +using Hangfire; +using Hangfire.InMemory; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class RatingServiceTests: AbstractDbTest +{ + private readonly RatingService _ratingService; + + public RatingServiceTests() + { + _ratingService = new RatingService(UnitOfWork, Substitute.For(), Substitute.For>()); + } + + [Fact] + public async Task UpdateRating_ShouldSetRating() + { + await ResetDb(); + + Context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + + await Context.SaveChangesAsync(); + + + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); + + JobStorage.Current = new InMemoryStorage(); + var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto + { + SeriesId = 1, + UserRating = 3, + }); + + Assert.True(result); + + var ratings = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))! + .Ratings; + Assert.NotEmpty(ratings); + Assert.Equal(3, ratings.First().Rating); + } + + [Fact] + public async Task UpdateRating_ShouldUpdateExistingRating() + { + await ResetDb(); + + Context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + + await Context.SaveChangesAsync(); + + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); + + var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto + { + SeriesId = 1, + UserRating = 3, + }); + + Assert.True(result); + + JobStorage.Current = new InMemoryStorage(); + var ratings = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings)) + .Ratings; + Assert.NotEmpty(ratings); + Assert.Equal(3, ratings.First().Rating); + + // Update the DB again + + var result2 = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto + { + SeriesId = 1, + UserRating = 5, + }); + + Assert.True(result2); + + var ratings2 = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings)) + .Ratings; + Assert.NotEmpty(ratings2); + Assert.True(ratings2.Count == 1); + Assert.Equal(5, ratings2.First().Rating); + } + + [Fact] + public async Task UpdateRating_ShouldClampRatingAt5() + { + await ResetDb(); + + Context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + await Context.SaveChangesAsync(); + + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); + + var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto + { + SeriesId = 1, + UserRating = 10, + }); + + Assert.True(result); + + JobStorage.Current = new InMemoryStorage(); + var ratings = (await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", + AppUserIncludes.Ratings)!) + .Ratings; + Assert.NotEmpty(ratings); + Assert.Equal(5, ratings.First().Rating); + } + + [Fact] + public async Task UpdateRating_ShouldReturnFalseWhenSeriesDoesntExist() + { + await ResetDb(); + + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + await Context.SaveChangesAsync(); + + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); + + var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto + { + SeriesId = 2, + UserRating = 5, + }); + + Assert.False(result); + + var ratings = user.Ratings; + Assert.Empty(ratings); + } + protected override async Task ResetDb() + { + Context.Series.RemoveRange(Context.Series.ToList()); + Context.AppUserRating.RemoveRange(Context.AppUserRating.ToList()); + Context.Genre.RemoveRange(Context.Genre.ToList()); + Context.CollectionTag.RemoveRange(Context.CollectionTag.ToList()); + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); + + await Context.SaveChangesAsync(); + } +} diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 468c22681..0e4ab2701 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1,30 +1,19 @@ using System.Collections.Generic; -using System.Data.Common; -using System.Globalization; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; -using API.Data; using API.Data.Repositories; -using API.DTOs; using API.DTOs.Progress; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; using API.Extensions; -using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; -using API.Services.Tasks; using API.SignalR; -using API.Tests.Helpers; -using AutoMapper; using Hangfire; using Hangfire.InMemory; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -32,30 +21,16 @@ using Xunit.Abstractions; namespace API.Tests.Services; -public class ReaderServiceTests +public class ReaderServiceTests: AbstractDbTest { private readonly ITestOutputHelper _testOutputHelper; - private readonly IUnitOfWork _unitOfWork; - private readonly DataContext _context; private readonly ReaderService _readerService; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string DataDirectory = "C:/data/"; - public ReaderServiceTests(ITestOutputHelper testOutputHelper) { _testOutputHelper = testOutputHelper; - var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options; - _context = new DataContext(contextOptions); - Task.Run(SeedDb).GetAwaiter().GetResult(); - - var config = new MapperConfiguration(cfg => cfg.AddProfile()); - var mapper = config.CreateMapper(); - _unitOfWork = new UnitOfWork(_context, mapper, null); - _readerService = new ReaderService(_unitOfWork, Substitute.For>(), + _readerService = new ReaderService(UnitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For()); @@ -63,55 +38,12 @@ public class ReaderServiceTests #region Setup - private static DbConnection CreateInMemoryDatabase() + + protected override async Task ResetDb() { - var connection = new SqliteConnection("Filename=:memory:"); + Context.Series.RemoveRange(Context.Series.ToList()); - connection.Open(); - - return connection; - } - - private async Task SeedDb() - { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); - - await Seed.SeedSettings(_context, - new DirectoryService(Substitute.For>(), filesystem)); - - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; - - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); - setting.Value = BackupDirectory; - - _context.ServerSetting.Update(setting); - - _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) - .Build()); - return await _context.SaveChangesAsync() > 0; - } - - private async Task ResetDb() - { - _context.Series.RemoveRange(_context.Series.ToList()); - - await _context.SaveChangesAsync(); - } - - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(DataDirectory); - - return fileSystem; + await Context.SaveChangesAsync(); } #endregion @@ -145,10 +77,10 @@ public class ReaderServiceTests series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); Assert.Equal(0, (await _readerService.CapPageToChapter(1, -1)).Item1); @@ -173,14 +105,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); JobStorage.Current = new InMemoryStorage(); @@ -194,7 +126,7 @@ public class ReaderServiceTests }, 1); Assert.True(successful); - Assert.NotNull(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); + Assert.NotNull(await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); } [Fact] @@ -211,14 +143,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); JobStorage.Current = new InMemoryStorage(); var successful = await _readerService.SaveReadingProgress(new ProgressDto() @@ -231,7 +163,7 @@ public class ReaderServiceTests }, 1); Assert.True(successful); - Assert.NotNull(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); + Assert.NotNull(await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); Assert.True(await _readerService.SaveReadingProgress(new ProgressDto() { @@ -242,7 +174,9 @@ public class ReaderServiceTests BookScrollId = "/h1/" }, 1)); - Assert.Equal("/h1/", (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).BookScrollId); + var userProgress = await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1); + Assert.NotNull(userProgress); + Assert.Equal("/h1/", userProgress.BookScrollId); } @@ -268,22 +202,24 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var volumes = await _unitOfWork.VolumeRepository.GetVolumes(1); - await _readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); - await _context.SaveChangesAsync(); + var volumes = await UnitOfWork.VolumeRepository.GetVolumes(1); + await _readerService.MarkChaptersAsRead(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await Context.SaveChangesAsync(); - Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + var userProgress = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + Assert.NotNull(userProgress); + Assert.Equal(2, userProgress.Progresses.Count); } #endregion @@ -306,27 +242,27 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); - await _readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + var volumes = (await UnitOfWork.VolumeRepository.GetVolumes(1)).ToList(); + await _readerService.MarkChaptersAsRead(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes[0].Chapters); - await _context.SaveChangesAsync(); - Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + await Context.SaveChangesAsync(); + Assert.Equal(2, (await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); - await _readerService.MarkChaptersAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); - await _context.SaveChangesAsync(); + await _readerService.MarkChaptersAsUnread(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes[0].Chapters); + await Context.SaveChangesAsync(); - var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; + var progresses = (await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; Assert.Equal(0, progresses.Max(p => p.PagesRead)); Assert.Equal(2, progresses.Count); } @@ -359,19 +295,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("2", actualChapter.Range); } @@ -393,17 +329,17 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("3-4", actualChapter.Volume.Name); Assert.Equal("1", actualChapter.Range); @@ -436,19 +372,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 2, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("31", actualChapter.Range); } @@ -476,18 +412,18 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("21", actualChapter.Range); } @@ -515,19 +451,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("21", actualChapter.Range); } @@ -550,18 +486,18 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 4, 1); Assert.NotEqual(-1, nextChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("21", actualChapter.Range); } @@ -587,21 +523,21 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); Assert.NotEqual(-1, nextChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, actualChapter.Range); } @@ -624,13 +560,13 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 4, 1); Assert.Equal(-1, nextChapter); @@ -649,13 +585,13 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.Equal(-1, nextChapter); @@ -674,13 +610,13 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.Equal(-1, nextChapter); @@ -703,13 +639,13 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.Equal(-1, nextChapter); @@ -739,13 +675,13 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); Assert.Equal(-1, nextChapter); @@ -777,19 +713,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.NotEqual(-1, nextChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("A.cbz", actualChapter.Range); } @@ -815,19 +751,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.NotEqual(-1, nextChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("A.cbz", actualChapter.Range); } @@ -857,14 +793,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 3, 4, 1); Assert.Equal(-1, nextChapter); @@ -894,19 +830,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); Assert.NotEqual(-1, nextChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter); Assert.NotNull(actualChapter); Assert.Equal("B.cbz", actualChapter.Range); } @@ -927,21 +863,21 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); var user = new AppUserBuilder("majora2007", "fake").Build(); - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); await _readerService.MarkChaptersAsRead(user, 1, new List() { - series.Volumes.First().Chapters.First() + series.Volumes[0].Chapters[0] }); var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter, ChapterIncludes.Volumes); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter, ChapterIncludes.Volumes); Assert.Equal(2, actualChapter.Volume.MinNumber); } @@ -973,19 +909,19 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 2, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("1", actualChapter.Range); } @@ -1013,18 +949,18 @@ public class ReaderServiceTests .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 3, 5, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("22", actualChapter.Range); } @@ -1062,20 +998,20 @@ public class ReaderServiceTests .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.Series.Add(series); + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // prevChapter should be id from ch.21 from volume 2001 var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 5, 7, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("21", actualChapter.Range); } @@ -1103,20 +1039,20 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("2", actualChapter.Range); } @@ -1139,21 +1075,21 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); Assert.Equal(2, prevChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("2", actualChapter.Range); } @@ -1171,14 +1107,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1199,14 +1135,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1232,14 +1168,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); Assert.Equal(-1, prevChapter); @@ -1269,20 +1205,20 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2,5, 1); - var chapterInfoDto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter); + var chapterInfoDto = await UnitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter); Assert.Equal(1, chapterInfoDto.ChapterNumber.AsFloat()); // This is first chapter of first volume @@ -1303,14 +1239,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1342,22 +1278,22 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 4, 1); Assert.NotEqual(-1, prevChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("A.cbz", actualChapter.Range); } @@ -1380,18 +1316,18 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 1, 1); Assert.NotEqual(-1, prevChapter); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); Assert.Equal("22", actualChapter.Range); } @@ -1412,16 +1348,16 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); var user = new AppUserBuilder("majora2007", "fake").Build(); - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 2, 1); - var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter, ChapterIncludes.Volumes); + var actualChapter = await UnitOfWork.ChapterRepository.GetChapterAsync(nextChapter, ChapterIncludes.Volumes); Assert.Equal(1, actualChapter.Volume.MinNumber); } @@ -1454,15 +1390,15 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1487,14 +1423,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1526,14 +1462,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1571,14 +1507,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1606,7 +1542,7 @@ public class ReaderServiceTests VolumeId = 2 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1650,14 +1586,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1679,7 +1615,7 @@ public class ReaderServiceTests VolumeId = 3 // Volume 2 id }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1705,14 +1641,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1738,14 +1674,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1773,7 +1709,7 @@ public class ReaderServiceTests VolumeId = 2 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1800,15 +1736,15 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1838,22 +1774,22 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); var user = new AppUser() { UserName = "majora2007" }; - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Mark everything but chapter 101 as read await _readerService.MarkSeriesAsRead(user, 1); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); // Unmark last chapter as read - var vol = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(1); + var vol = await UnitOfWork.VolumeRepository.GetVolumeByIdAsync(1); foreach (var chapt in vol.Chapters) { await _readerService.SaveReadingProgress(new ProgressDto() @@ -1864,7 +1800,7 @@ public class ReaderServiceTests VolumeId = 1 }, 1); } - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1892,22 +1828,22 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); var user = new AppUser() { UserName = "majora2007" }; - _context.AppUser.Add(user); + Context.AppUser.Add(user); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Mark everything but chapter 101 as read await _readerService.MarkSeriesAsRead(user, 1); - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); // Unmark last chapter as read - var vol = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(1); + var vol = await UnitOfWork.VolumeRepository.GetVolumeByIdAsync(1); await _readerService.SaveReadingProgress(new ProgressDto() { PageNum = 0, @@ -1922,7 +1858,7 @@ public class ReaderServiceTests SeriesId = 1, VolumeId = 1 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -1945,14 +1881,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -1979,7 +1915,7 @@ public class ReaderServiceTests VolumeId = 2 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2005,21 +1941,21 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Save progress on first volume chapters and 1st of second volume - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); await _readerService.MarkSeriesAsRead(user, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2043,14 +1979,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); @@ -2077,7 +2013,7 @@ public class ReaderServiceTests VolumeId = 1 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2106,20 +2042,20 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); await _readerService.MarkSeriesAsRead(user, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Add 2 new unread series to the Series series.Volumes[0].Chapters.Add(new ChapterBuilder("231") @@ -2128,8 +2064,8 @@ public class ReaderServiceTests series.Volumes[2].Chapters.Add(new ChapterBuilder("14.9") .WithPages(1) .Build()); - _context.Series.Attach(series); - await _context.SaveChangesAsync(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); // This tests that if you add a series later to a volume and a loose leaf chapter, we continue from that volume, rather than loose leaf var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2169,26 +2105,26 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Save progress on first volume chapters and 1st of second volume - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); await _readerService.MarkChaptersAsRead(user, 1, new List() { readChapter1, readChapter2 }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2225,14 +2161,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); await _readerService.SaveReadingProgress(new ProgressDto() { @@ -2290,7 +2226,7 @@ public class ReaderServiceTests VolumeId = 2 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2319,14 +2255,14 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); await _readerService.SaveReadingProgress(new ProgressDto() { @@ -2336,7 +2272,7 @@ public class ReaderServiceTests VolumeId = 1 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2351,7 +2287,7 @@ public class ReaderServiceTests SeriesId = 1, VolumeId = 1 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2366,7 +2302,7 @@ public class ReaderServiceTests SeriesId = 1, VolumeId = 1 }, 1); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); nextChapter = await _readerService.GetContinuePoint(1, 1); @@ -2396,26 +2332,26 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await _readerService.MarkChaptersUntilAsRead(user, 1, 5); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Validate correct chapters have read status - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1))); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1))); } [Fact] @@ -2436,27 +2372,27 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await _readerService.MarkChaptersUntilAsRead(user, 1, 2.5f); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Validate correct chapters have read status - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1))); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1))); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1)).PagesRead); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(4, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1))); } [Fact] @@ -2474,23 +2410,24 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + Assert.NotNull(user); await _readerService.MarkChaptersUntilAsRead(user, 1, 2); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Validate correct chapters have read status - Assert.True(await _unitOfWork.AppUserProgressRepository.UserHasProgress(LibraryType.Manga, 1)); + Assert.True(await UnitOfWork.AppUserProgressRepository.UserHasProgress(LibraryType.Manga, 1)); } [Fact] @@ -2525,24 +2462,24 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); const int markReadUntilNumber = 47; await _readerService.MarkChaptersUntilAsRead(user, 1, markReadUntilNumber); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(1, 1); + var volumes = await UnitOfWork.VolumeRepository.GetVolumesDtoAsync(1, 1); Assert.True(volumes.SelectMany(v => v.Chapters).All(c => { // Specials are ignored. @@ -2579,21 +2516,21 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - await _readerService.MarkSeriesAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); - await _context.SaveChangesAsync(); + await _readerService.MarkSeriesAsRead(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); + await Context.SaveChangesAsync(); - Assert.Equal(4, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + Assert.Equal(4, (await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); } @@ -2614,27 +2551,27 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); - await _readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + var volumes = (await UnitOfWork.VolumeRepository.GetVolumes(1)).ToList(); + await _readerService.MarkChaptersAsRead(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes[0].Chapters); - await _context.SaveChangesAsync(); - Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + await Context.SaveChangesAsync(); + Assert.Equal(2, (await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); - await _readerService.MarkSeriesAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); - await _context.SaveChangesAsync(); + await _readerService.MarkSeriesAsUnread(await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); + await Context.SaveChangesAsync(); - var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; + var progresses = (await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; Assert.Equal(0, progresses.Max(p => p.PagesRead)); Assert.Equal(2, progresses.Count); } @@ -2702,31 +2639,32 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await _readerService.MarkVolumesUntilAsRead(user, 1, 2002); - await _context.SaveChangesAsync(); + Assert.NotNull(user); + await Context.SaveChangesAsync(); // Validate loose leaf chapters don't get marked as read - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1))); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1))); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); // Validate that volumes 1997 and 2002 both have their respective chapter 0 marked as read - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1)).PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1)).PagesRead); // Validate that the chapter 0 of the following volume (2003) is not read - Assert.Null(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(7, 1)); + Assert.Null(await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(7, 1)); } @@ -2757,30 +2695,31 @@ public class ReaderServiceTests .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - _context.Series.Add(series); + Context.Series.Add(series); - _context.AppUser.Add(new AppUser() + Context.AppUser.Add(new AppUser() { UserName = "majora2007" }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + var user = await UnitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + Assert.NotNull(user); await _readerService.MarkVolumesUntilAsRead(user, 1, 2002); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Validate loose leaf chapters don't get marked as read - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1))); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1))); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(2, 1))); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); // Validate volumes chapter 0 have read status - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1)).PagesRead); - Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1)).PagesRead); - Assert.Null((await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(5, 1))?.PagesRead); + Assert.Equal(1, (await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(6, 1))?.PagesRead); + Assert.Null((await UnitOfWork.AppUserProgressRepository.GetUserProgressAsync(3, 1))); } #endregion diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 96a3effa4..7a6ed3e0b 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -11,15 +11,11 @@ using API.DTOs.ReadingLists; using API.DTOs.ReadingLists.CBL; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; -using API.Services.Tasks; using API.SignalR; -using API.Tests.Helpers; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -583,6 +579,93 @@ public class ReadingListServiceTests Assert.Equal(AgeRating.G, readingList.AgeRating); } + [Fact] + public async Task UpdateReadingListAgeRatingForSeries() + { + await ResetDb(); + var spiceAndWolf = new SeriesBuilder("Spice and Wolf") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithVolumes([ + new VolumeBuilder("1") + .WithChapters([ + new ChapterBuilder("1").Build(), + new ChapterBuilder("2").Build(), + ]).Build() + ]).Build(); + spiceAndWolf.Metadata.AgeRating = AgeRating.Everyone; + + var othersidePicnic = new SeriesBuilder("Otherside Picnic ") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithVolumes([ + new VolumeBuilder("1") + .WithChapters([ + new ChapterBuilder("1").Build(), + new ChapterBuilder("2").Build(), + ]).Build() + ]).Build(); + othersidePicnic.Metadata.AgeRating = AgeRating.Everyone; + + _context.AppUser.Add(new AppUser() + { + UserName = "Amelia", + ReadingLists = new List(), + Libraries = new List + { + new LibraryBuilder("Test Library", LibraryType.LightNovel) + .WithSeries(spiceAndWolf) + .WithSeries(othersidePicnic) + .Build(), + }, + }); + + await _context.SaveChangesAsync(); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("Amelia", AppUserIncludes.ReadingLists); + Assert.NotNull(user); + + var myTestReadingList = new ReadingListBuilder("MyReadingList").Build(); + var mySecondTestReadingList = new ReadingListBuilder("MySecondReadingList").Build(); + var myThirdTestReadingList = new ReadingListBuilder("MyThirdReadingList").Build(); + user.ReadingLists = new List() + { + myTestReadingList, + mySecondTestReadingList, + myThirdTestReadingList, + }; + + + await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List {1, 2}, myTestReadingList); + await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List {3, 4}, myTestReadingList); + await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List {1, 2}, myThirdTestReadingList); + await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List {3, 4}, mySecondTestReadingList); + + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + await _readingListService.CalculateReadingListAgeRating(myTestReadingList); + await _readingListService.CalculateReadingListAgeRating(mySecondTestReadingList); + Assert.Equal(AgeRating.Everyone, myTestReadingList.AgeRating); + Assert.Equal(AgeRating.Everyone, mySecondTestReadingList.AgeRating); + Assert.Equal(AgeRating.Everyone, myThirdTestReadingList.AgeRating); + + await _readingListService.UpdateReadingListAgeRatingForSeries(othersidePicnic.Id, AgeRating.Mature); + await _unitOfWork.CommitAsync(); + + // Reading lists containing Otherside Picnic are updated + myTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + Assert.NotNull(myTestReadingList); + Assert.Equal(AgeRating.Mature, myTestReadingList.AgeRating); + + mySecondTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(2); + Assert.NotNull(mySecondTestReadingList); + Assert.Equal(AgeRating.Mature, mySecondTestReadingList.AgeRating); + + // Unrelated reading list is not updated + myThirdTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(3); + Assert.NotNull(myThirdTestReadingList); + Assert.Equal(AgeRating.Everyone, myThirdTestReadingList.AgeRating); + } + #endregion #region CalculateStartAndEndDates @@ -713,6 +796,9 @@ public class ReadingListServiceTests Assert.Equal("Issue #1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", "1", "The Title"))); Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", chapterTitleName: "The Title"))); Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, chapterTitleName: "The Title"))); + var dto = CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, chapterNumber: "The Special Title"); + dto.IsSpecial = true; + Assert.Equal("The Special Title", ReadingListService.FormatTitle(dto)); // Book Library & Archive Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1"))); diff --git a/API.Tests/Services/ReadingProfileServiceTest.cs b/API.Tests/Services/ReadingProfileServiceTest.cs new file mode 100644 index 000000000..b3d81e5ac --- /dev/null +++ b/API.Tests/Services/ReadingProfileServiceTest.cs @@ -0,0 +1,561 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Helpers.Builders; +using API.Services; +using API.Tests.Helpers; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class ReadingProfileServiceTest: AbstractDbTest +{ + + /// + /// Does not add a default reading profile + /// + /// + public async Task<(ReadingProfileService, AppUser, Library, Series)> Setup() + { + var user = new AppUserBuilder("amelia", "amelia@localhost").Build(); + Context.AppUser.Add(user); + await UnitOfWork.CommitAsync(); + + var series = new SeriesBuilder("Spice and Wolf").Build(); + + var library = new LibraryBuilder("Manga") + .WithSeries(series) + .Build(); + + user.Libraries.Add(library); + await UnitOfWork.CommitAsync(); + + var rps = new ReadingProfileService(UnitOfWork, Substitute.For(), Mapper); + user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.UserPreferences); + + return (rps, user, library, series); + } + + [Fact] + public async Task ImplicitProfileFirst() + { + await ResetDb(); + var (rps, user, library, series) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Implicit) + .WithSeries(series) + .WithName("Implicit Profile") + .Build(); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Non-implicit Profile") + .Build(); + + user.ReadingProfiles.Add(profile); + user.ReadingProfiles.Add(profile2); + await UnitOfWork.CommitAsync(); + + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal("Implicit Profile", seriesProfile.Name); + + // Find parent + seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id, true); + Assert.NotNull(seriesProfile); + Assert.Equal("Non-implicit Profile", seriesProfile.Name); + } + + [Fact] + public async Task CantDeleteDefaultReadingProfile() + { + await ResetDb(); + var (rps, user, _, _) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Default) + .Build(); + Context.AppUserReadingProfiles.Add(profile); + await UnitOfWork.CommitAsync(); + + await Assert.ThrowsAsync(async () => + { + await rps.DeleteReadingProfile(user.Id, profile.Id); + }); + + var profile2 = new AppUserReadingProfileBuilder(user.Id).Build(); + Context.AppUserReadingProfiles.Add(profile2); + await UnitOfWork.CommitAsync(); + + await rps.DeleteReadingProfile(user.Id, profile2.Id); + await UnitOfWork.CommitAsync(); + + var allProfiles = await Context.AppUserReadingProfiles.ToListAsync(); + Assert.Single(allProfiles); + } + + [Fact] + public async Task CreateImplicitSeriesReadingProfile() + { + await ResetDb(); + var (rps, user, _, series) = await Setup(); + + var dto = new UserReadingProfileDto + { + ReaderMode = ReaderMode.Webtoon, + ScalingOption = ScalingOption.FitToHeight, + WidthOverride = 53, + }; + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto); + + var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Contains(profile.SeriesIds, s => s == series.Id); + Assert.Equal(ReadingProfileKind.Implicit, profile.Kind); + } + + [Fact] + public async Task UpdateImplicitReadingProfile_DoesNotCreateNew() + { + await ResetDb(); + var (rps, user, _, series) = await Setup(); + + var dto = new UserReadingProfileDto + { + ReaderMode = ReaderMode.Webtoon, + ScalingOption = ScalingOption.FitToHeight, + WidthOverride = 53, + }; + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto); + + var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Contains(profile.SeriesIds, s => s == series.Id); + Assert.Equal(ReadingProfileKind.Implicit, profile.Kind); + + dto = new UserReadingProfileDto + { + ReaderMode = ReaderMode.LeftRight, + }; + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto); + profile = await rps.GetReadingProfileForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Contains(profile.SeriesIds, s => s == series.Id); + Assert.Equal(ReadingProfileKind.Implicit, profile.Kind); + Assert.Equal(ReaderMode.LeftRight, profile.ReaderMode); + + var implicitCount = await Context.AppUserReadingProfiles + .Where(p => p.Kind == ReadingProfileKind.Implicit) + .CountAsync(); + Assert.Equal(1, implicitCount); + } + + [Fact] + public async Task GetCorrectProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Series Specific") + .Build(); + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithLibrary(lib) + .WithName("Library Specific") + .Build(); + var profile3 = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Default) + .WithName("Global") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + Context.AppUserReadingProfiles.Add(profile2); + Context.AppUserReadingProfiles.Add(profile3); + + var series2 = new SeriesBuilder("Rainbows After Storms").Build(); + lib.Series.Add(series2); + + var lib2 = new LibraryBuilder("Manga2").Build(); + var series3 = new SeriesBuilder("A Tropical Fish Yearns for Snow").Build(); + lib2.Series.Add(series3); + + user.Libraries.Add(lib2); + await UnitOfWork.CommitAsync(); + + var p = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(p); + Assert.Equal("Series Specific", p.Name); + + p = await rps.GetReadingProfileDtoForSeries(user.Id, series2.Id); + Assert.NotNull(p); + Assert.Equal("Library Specific", p.Name); + + p = await rps.GetReadingProfileDtoForSeries(user.Id, series3.Id); + Assert.NotNull(p); + Assert.Equal("Global", p.Name); + } + + [Fact] + public async Task ReplaceReadingProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var profile1 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile 1") + .Build(); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithName("Profile 2") + .Build(); + + Context.AppUserReadingProfiles.Add(profile1); + Context.AppUserReadingProfiles.Add(profile2); + await UnitOfWork.CommitAsync(); + + var profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Equal("Profile 1", profile.Name); + + await rps.AddProfileToSeries(user.Id, profile2.Id, series.Id); + profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Equal("Profile 2", profile.Name); + } + + [Fact] + public async Task DeleteReadingProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var profile1 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile 1") + .Build(); + + Context.AppUserReadingProfiles.Add(profile1); + await UnitOfWork.CommitAsync(); + + await rps.ClearSeriesProfile(user.Id, series.Id); + var profiles = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id); + Assert.DoesNotContain(profiles, rp => rp.SeriesIds.Contains(series.Id)); + + } + + [Fact] + public async Task BulkAddReadingProfiles() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + for (var i = 0; i < 10; i++) + { + var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build(); + lib.Series.Add(generatedSeries); + } + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile2") + .Build(); + Context.AppUserReadingProfiles.Add(profile2); + + await UnitOfWork.CommitAsync(); + + var someSeriesIds = lib.Series.Take(lib.Series.Count / 2).Select(s => s.Id).ToList(); + await rps.BulkAddProfileToSeries(user.Id, profile.Id, someSeriesIds); + + foreach (var id in someSeriesIds) + { + var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(foundProfile); + Assert.Equal(profile.Id, foundProfile.Id); + } + + var allIds = lib.Series.Select(s => s.Id).ToList(); + await rps.BulkAddProfileToSeries(user.Id, profile2.Id, allIds); + + foreach (var id in allIds) + { + var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(foundProfile); + Assert.Equal(profile2.Id, foundProfile.Id); + } + + + } + + [Fact] + public async Task BulkAssignDeletesImplicit() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var implicitProfile = Mapper.Map(new AppUserReadingProfileBuilder(user.Id) + .Build()); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Profile 1") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + + for (var i = 0; i < 10; i++) + { + var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build(); + lib.Series.Add(generatedSeries); + } + await UnitOfWork.CommitAsync(); + + var ids = lib.Series.Select(s => s.Id).ToList(); + + foreach (var id in ids) + { + await rps.UpdateImplicitReadingProfile(user.Id, id, implicitProfile); + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind); + } + + await rps.BulkAddProfileToSeries(user.Id, profile.Id, ids); + + foreach (var id in ids) + { + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind); + } + + var implicitCount = await Context.AppUserReadingProfiles + .Where(p => p.Kind == ReadingProfileKind.Implicit) + .CountAsync(); + Assert.Equal(0, implicitCount); + } + + [Fact] + public async Task AddDeletesImplicit() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var implicitProfile = Mapper.Map(new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Implicit) + .Build()); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Profile 1") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + await UnitOfWork.CommitAsync(); + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, implicitProfile); + + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind); + + await rps.AddProfileToSeries(user.Id, profile.Id, series.Id); + + seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind); + + var implicitCount = await Context.AppUserReadingProfiles + .Where(p => p.Kind == ReadingProfileKind.Implicit) + .CountAsync(); + Assert.Equal(0, implicitCount); + } + + [Fact] + public async Task CreateReadingProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var dto = new UserReadingProfileDto + { + Name = "Profile 1", + ReaderMode = ReaderMode.LeftRight, + EmulateBook = false, + }; + + await rps.CreateReadingProfile(user.Id, dto); + + var dto2 = new UserReadingProfileDto + { + Name = "Profile 2", + ReaderMode = ReaderMode.LeftRight, + EmulateBook = false, + }; + + await rps.CreateReadingProfile(user.Id, dto2); + + var dto3 = new UserReadingProfileDto + { + Name = "Profile 1", // Not unique name + ReaderMode = ReaderMode.LeftRight, + EmulateBook = false, + }; + + await Assert.ThrowsAsync(async () => + { + await rps.CreateReadingProfile(user.Id, dto3); + }); + + var allProfiles = Context.AppUserReadingProfiles.ToList(); + Assert.Equal(2, allProfiles.Count); + } + + [Fact] + public async Task ClearSeriesProfile_RemovesImplicitAndUnlinksExplicit() + { + await ResetDb(); + var (rps, user, _, series) = await Setup(); + + var implicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithKind(ReadingProfileKind.Implicit) + .WithName("Implicit Profile") + .Build(); + + var explicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Explicit Profile") + .Build(); + + Context.AppUserReadingProfiles.Add(implicitProfile); + Context.AppUserReadingProfiles.Add(explicitProfile); + await UnitOfWork.CommitAsync(); + + var allBefore = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id); + Assert.Equal(2, allBefore.Count(rp => rp.SeriesIds.Contains(series.Id))); + + await rps.ClearSeriesProfile(user.Id, series.Id); + + var remainingProfiles = await Context.AppUserReadingProfiles.ToListAsync(); + Assert.Single(remainingProfiles); + Assert.Equal("Explicit Profile", remainingProfiles[0].Name); + Assert.Empty(remainingProfiles[0].SeriesIds); + + var profilesForSeries = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id); + Assert.DoesNotContain(profilesForSeries, rp => rp.SeriesIds.Contains(series.Id)); + } + + [Fact] + public async Task AddProfileToLibrary_AddsAndOverridesExisting() + { + await ResetDb(); + var (rps, user, lib, _) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Library Profile") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + await UnitOfWork.CommitAsync(); + + await rps.AddProfileToLibrary(user.Id, profile.Id, lib.Id); + await UnitOfWork.CommitAsync(); + + var linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.NotNull(linkedProfile); + Assert.Equal(profile.Id, linkedProfile.Id); + + var newProfile = new AppUserReadingProfileBuilder(user.Id) + .WithName("New Profile") + .Build(); + Context.AppUserReadingProfiles.Add(newProfile); + await UnitOfWork.CommitAsync(); + + await rps.AddProfileToLibrary(user.Id, newProfile.Id, lib.Id); + await UnitOfWork.CommitAsync(); + + linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.NotNull(linkedProfile); + Assert.Equal(newProfile.Id, linkedProfile.Id); + } + + [Fact] + public async Task ClearLibraryProfile_RemovesImplicitOrUnlinksExplicit() + { + await ResetDb(); + var (rps, user, lib, _) = await Setup(); + + var implicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Implicit) + .WithLibrary(lib) + .Build(); + Context.AppUserReadingProfiles.Add(implicitProfile); + await UnitOfWork.CommitAsync(); + + await rps.ClearLibraryProfile(user.Id, lib.Id); + var profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.Null(profile); + + var explicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithLibrary(lib) + .Build(); + Context.AppUserReadingProfiles.Add(explicitProfile); + await UnitOfWork.CommitAsync(); + + await rps.ClearLibraryProfile(user.Id, lib.Id); + profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.Null(profile); + + var stillExists = await Context.AppUserReadingProfiles.FindAsync(explicitProfile.Id); + Assert.NotNull(stillExists); + } + + /// + /// As response to #3793, I'm not sure if we want to keep this. It's not the most nice. But I think the idea of this test + /// is worth having. + /// + [Fact] + public void UpdateFields_UpdatesAll() + { + // Repeat to ensure booleans are flipped and actually tested + for (int i = 0; i < 10; i++) + { + var profile = new AppUserReadingProfile(); + var dto = new UserReadingProfileDto(); + + RandfHelper.SetRandomValues(profile); + RandfHelper.SetRandomValues(dto); + + ReadingProfileService.UpdateReaderProfileFields(profile, dto); + + var newDto = Mapper.Map(profile); + + Assert.True(RandfHelper.AreSimpleFieldsEqual(dto, newDto, + ["k__BackingField", "k__BackingField"])); + } + } + + + + protected override async Task ResetDb() + { + Context.AppUserReadingProfiles.RemoveRange(Context.AppUserReadingProfiles); + await UnitOfWork.CommitAsync(); + } +} diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index fcbfe8260..c337d2311 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -1,29 +1,16 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; -using System.IO.Compression; using System.Linq; -using System.Text; -using System.Text.Json; using System.Threading.Tasks; -using API.Data; +using API.Data.Metadata; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; -using API.Helpers; -using API.Helpers.Builders; -using API.Services; -using API.Services.Plus; -using API.Services.Tasks; -using API.Services.Tasks.Metadata; -using API.Services.Tasks.Scanner; +using API.Extensions; using API.Services.Tasks.Scanner.Parser; -using API.SignalR; using API.Tests.Helpers; using Hangfire; -using Microsoft.Extensions.Logging; -using NSubstitute; using Xunit; using Xunit.Abstractions; @@ -32,9 +19,8 @@ namespace API.Tests.Services; public class ScannerServiceTests : AbstractDbTest { private readonly ITestOutputHelper _testOutputHelper; + private readonly ScannerHelper _scannerHelper; private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); - private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases"); - private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png"); public ScannerServiceTests(ITestOutputHelper testOutputHelper) { @@ -42,22 +28,45 @@ public class ScannerServiceTests : AbstractDbTest // Set up Hangfire to use in-memory storage for testing GlobalConfiguration.Configuration.UseInMemoryStorage(); + _scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper); } protected override async Task ResetDb() { - _context.Library.RemoveRange(_context.Library); - await _context.SaveChangesAsync(); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + + protected async Task SetAllSeriesLastScannedInThePast(Library library, TimeSpan? duration = null) + { + foreach (var series in library.Series) + { + await SetLastScannedInThePast(series, duration, false); + } + await Context.SaveChangesAsync(); + } + + protected async Task SetLastScannedInThePast(Series series, TimeSpan? duration = null, bool save = true) + { + duration ??= TimeSpan.FromMinutes(2); + series.LastFolderScanned = DateTime.Now.Subtract(duration.Value); + Context.Series.Update(series); + + if (save) + { + await Context.SaveChangesAsync(); + } } [Fact] public async Task ScanLibrary_ComicVine_PublisherFolder() { var testcase = "Publisher - ComicVine.json"; - var library = await GenerateScannerData(testcase); - var scanner = CreateServices(); + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Equal(4, postLib.Series.Count); @@ -67,10 +76,10 @@ public class ScannerServiceTests : AbstractDbTest public async Task ScanLibrary_ShouldCombineNestedFolder() { var testcase = "Series and Series-Series Combined - Manga.json"; - var library = await GenerateScannerData(testcase); - var scanner = CreateServices(); + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -81,11 +90,11 @@ public class ScannerServiceTests : AbstractDbTest [Fact] public async Task ScanLibrary_FlatSeries() { - var testcase = "Flat Series - Manga.json"; - var library = await GenerateScannerData(testcase); - var scanner = CreateServices(); + const string testcase = "Flat Series - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -97,11 +106,26 @@ public class ScannerServiceTests : AbstractDbTest [Fact] public async Task ScanLibrary_FlatSeriesWithSpecialFolder() { - var testcase = "Flat Series with Specials Folder - Manga.json"; - var library = await GenerateScannerData(testcase); - var scanner = CreateServices(); + const string testcase = "Flat Series with Specials Folder Alt Naming - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(4, postLib.Series.First().Volumes.Count); + Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); + } + + [Fact] + public async Task ScanLibrary_FlatSeriesWithSpecialFolder_AlternativeNaming() + { + const string testcase = "Flat Series with Specials Folder Alt Naming - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -114,10 +138,10 @@ public class ScannerServiceTests : AbstractDbTest { const string testcase = "Flat Special - Manga.json"; - var library = await GenerateScannerData(testcase); - var scanner = CreateServices(); + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); @@ -125,171 +149,850 @@ public class ScannerServiceTests : AbstractDbTest Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); } - private async Task GenerateScannerData(string testcase) + [Fact] + public async Task ScanLibrary_SeriesWithUnbalancedParenthesis() { - var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase)); + const string testcase = "Scan Library Parses as ( - Manga.json"; - var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase)); + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); - var library = new LibraryBuilder(publisher, type) - .WithFolders([new FolderPath() {Path = testDirectoryPath}]) - .Build(); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); - var admin = new AppUserBuilder("admin", "admin@kavita.com", Seed.DefaultThemes[0]) - .WithLibrary(library) - .Build(); + var series = postLib.Series.First(); - _unitOfWork.UserRepository.Add(admin); // Admin is needed for generating collections/reading lists - _unitOfWork.LibraryRepository.Add(library); - await _unitOfWork.CommitAsync(); - - return library; + Assert.Equal("Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE", series.Name); } - private ScannerService CreateServices() + /// + /// This is testing that if the first file is named A and has a localized name of B if all other files are named B, it should still group and name the series A + /// + [Fact] + public async Task ScanLibrary_LocalizedSeries() { - var ds = new DirectoryService(Substitute.For>(), new FileSystem()); - var mockReadingService = new MockReadingItemService(ds, Substitute.For()); - var processSeries = new ProcessSeries(_unitOfWork, Substitute.For>(), - Substitute.For(), - ds, Substitute.For(), mockReadingService, Substitute.For(), - Substitute.For(), - Substitute.For(), - Substitute.For(), - Substitute.For()); + const string testcase = "Series with Localized - Manga.json"; - var scanner = new ScannerService(_unitOfWork, Substitute.For>(), - Substitute.For(), - Substitute.For(), Substitute.For(), ds, - mockReadingService, processSeries, Substitute.For()); - return scanner; + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("My Dress-Up Darling v01.cbz", new ComicInfo() + { + Series = "My Dress-Up Darling", + LocalizedSeries = "Sono Bisque Doll wa Koi wo Suru" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(3, postLib.Series.First().Volumes.Count); } - private static (string Publisher, LibraryType Type) SplitPublisherAndLibraryType(string input) + [Fact] + public async Task ScanLibrary_LocalizedSeries2() { - // Split the input string based on " - " - var parts = input.Split(" - ", StringSplitOptions.RemoveEmptyEntries); + const string testcase = "Series with Localized 2 - Manga.json"; - if (parts.Length != 2) + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Immoral Guild v01.cbz", new ComicInfo() { - throw new ArgumentException("Input must be in the format 'Publisher - LibraryType'"); - } + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase + }); - var publisher = parts[0].Trim(); - var libraryTypeString = parts[1].Trim(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); - // Try to parse the right-hand side as a LibraryType enum - if (!Enum.TryParse(libraryTypeString, out var libraryType)) - { - throw new ArgumentException($"'{libraryTypeString}' is not a valid LibraryType"); - } - return (publisher, libraryType); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Immoral Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Equal(3, s.Volumes.Count); } - - private async Task GenerateTestDirectory(string mapPath) + /// + /// Special Keywords shouldn't be removed from the series name and thus these 2 should group + /// + [Fact] + public async Task ScanLibrary_ExtraShouldNotAffect() { - // Read the map file - var mapContent = await File.ReadAllTextAsync(mapPath); + const string testcase = "Series with Extra - Manga.json"; - // Deserialize the JSON content into a list of strings using System.Text.Json - var filePaths = JsonSerializer.Deserialize>(mapContent); - - // Create a test directory - var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(mapPath)); - if (Directory.Exists(testDirectory)) + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Vol.01.cbz", new ComicInfo() { - Directory.Delete(testDirectory, true); - } - Directory.CreateDirectory(testDirectory); + Series = "The Novel's Extra", + }); - // Generate the files and folders - await Scaffold(testDirectory, filePaths); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); - _testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}"); - return testDirectory; + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("The Novel's Extra", s.Name); + Assert.Equal(2, s.Volumes.Count); } - private async Task Scaffold(string testDirectory, List filePaths) + /// + /// Files under a folder with a SP marker should group into one issue + /// + /// https://github.com/Kareadita/Kavita/issues/3299 + [Fact] + public async Task ScanLibrary_ImageSeries_SpecialGrouping() { - foreach (var relativePath in filePaths) - { - var fullPath = Path.Combine(testDirectory, relativePath); - var fileDir = Path.GetDirectoryName(fullPath); + const string testcase = "Image Series with SP Folder - Manga.json"; - // Create the directory if it doesn't exist - if (!Directory.Exists(fileDir)) - { - Directory.CreateDirectory(fileDir); - Console.WriteLine($"Created directory: {fileDir}"); - } + var library = await _scannerHelper.GenerateScannerData(testcase); - var ext = Path.GetExtension(fullPath).ToLower(); - if (new[] { ".cbz", ".cbr", ".zip", ".rar" }.Contains(ext)) - { - CreateMinimalCbz(fullPath, includeMetadata: true); - } - else - { - // Create an empty file - await File.Create(fullPath).DisposeAsync(); - Console.WriteLine($"Created empty file: {fullPath}"); - } - } + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(3, postLib.Series.First().Volumes.Count); } - private void CreateMinimalCbz(string filePath, bool includeMetadata) + /// + /// This test is currently disabled because the Image parser is unable to support multiple files mapping into one single Special. + /// https://github.com/Kareadita/Kavita/issues/3299 + /// + public async Task ScanLibrary_ImageSeries_SpecialGrouping_NonEnglish() { - var tempImagePath = _imagePath; // Assuming _imagePath is a valid path to the 1x1 image + const string testcase = "Image Series with SP Folder (Non English) - Image.json"; - using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create)) - { - // Add the 1x1 image to the archive - archive.CreateEntryFromFile(tempImagePath, "1x1.png"); + var library = await _scannerHelper.GenerateScannerData(testcase); - if (includeMetadata) - { - var comicInfo = GenerateComicInfo(); - var entry = archive.CreateEntry("ComicInfo.xml"); - using var entryStream = entry.Open(); - using var writer = new StreamWriter(entryStream, Encoding.UTF8); - writer.Write(comicInfo); - } - } - Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(includeMetadata ? "" : "out")} metadata."); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var series = postLib.Series.First(); + Assert.Equal(3, series.Volumes.Count); + var specialVolume = series.Volumes.FirstOrDefault(v => v.Name == Parser.SpecialVolume); + Assert.NotNull(specialVolume); + Assert.Single(specialVolume.Chapters); + Assert.True(specialVolume.Chapters.First().IsSpecial); + //Assert.Equal("葬送のフリーレン 公式ファンブック SP01", specialVolume.Chapters.First().Title); } - private string GenerateComicInfo() + + [Fact] + public async Task ScanLibrary_PublishersInheritFromChapters() { - var comicInfo = new StringBuilder(); - comicInfo.AppendLine(""); - comicInfo.AppendLine(""); + const string testcase = "Flat Special - Manga.json"; - // People Tags - string[] people = { "Joe Shmo", "Tommy Two Hands"}; - string[] genres = { /* Your list of genres here */ }; - - void AddRandomTag(string tagName, string[] choices) + var infos = new Dictionary(); + infos.Add("Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz", new ComicInfo() { - if (new Random().Next(0, 2) == 1) // 50% chance to include the tag - { - var selected = choices.OrderBy(x => Guid.NewGuid()).Take(new Random().Next(1, 5)).ToArray(); - comicInfo.AppendLine($" <{tagName}>{string.Join(", ", selected)}"); - } - } - - foreach (var tag in new[] { "Writer", "Penciller", "Inker", "CoverArtist", "Publisher", "Character", "Imprint", "Colorist", "Letterer", "Editor", "Translator", "Team", "Location" }) + Publisher = "Correct Publisher" + }); + infos.Add("Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", new ComicInfo() { - AddRandomTag(tag, people); + Publisher = "Special Publisher" + }); + infos.Add("Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", new ComicInfo() + { + Publisher = "Chapter Publisher" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var publishers = postLib.Series.First().Metadata.People + .Where(p => p.Role == PersonRole.Publisher); + Assert.Equal(3, publishers.Count()); + } + + + /// + /// Tests that pdf parser handles the loose chapters correctly + /// https://github.com/Kareadita/Kavita/issues/3148 + /// + [Fact] + public async Task ScanLibrary_LooseChapters_Pdf() + { + const string testcase = "PDF Comic Chapters - Comic.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var series = postLib.Series.First(); + Assert.Single(series.Volumes); + Assert.Equal(4, series.Volumes.First().Chapters.Count); + } + + [Fact] + public async Task ScanLibrary_LooseChapters_Pdf_LN() + { + const string testcase = "PDF Comic Chapters - LightNovel.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var series = postLib.Series.First(); + Assert.Single(series.Volumes); + Assert.Equal(4, series.Volumes.First().Chapters.Count); + } + + /// + /// This is the same as doing ScanFolder as the case where it can find the series is just ScanSeries + /// + [Fact] + public async Task ScanSeries_NewChapterInNestedFolder() + { + const string testcase = "Series with Localized - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("My Dress-Up Darling v01.cbz", new ComicInfo() + { + Series = "My Dress-Up Darling", + LocalizedSeries = "Sono Bisque Doll wa Koi wo Suru" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var series = postLib.Series.First(); + Assert.Equal(3, series.Volumes.Count); + + // Bootstrap a new file in the nested "Sono Bisque Doll wa Koi wo Suru" directory and perform a series scan + var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(testcase)); + await _scannerHelper.Scaffold(testDirectory, ["My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru ch 11.cbz"]); + + // Now that a new file exists in the subdirectory, scan again + await scanner.ScanSeries(series.Id); + Assert.Single(postLib.Series); + Assert.Equal(3, series.Volumes.Count); + Assert.Equal(2, series.Volumes.First(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber)).Chapters.Count); + } + + [Fact] + public async Task ScanLibrary_LocalizedSeries_MatchesFilename() + { + const string testcase = "Localized Name matches Filename - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Futoku no Guild v01.cbz", new ComicInfo() + { + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Immoral Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Single(s.Volumes); + } + + [Fact] + public async Task ScanLibrary_LocalizedSeries_MatchesFilename_SameNames() + { + const string testcase = "Localized Name matches Filename - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Futoku no Guild v01.cbz", new ComicInfo() + { + Series = "Futoku no Guild", + LocalizedSeries = "Futoku no Guild" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Futoku no Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Single(s.Volumes); + } + + [Fact] + public async Task ScanLibrary_ExcludePattern_Works() + { + const string testcase = "Exclude Pattern 1 - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**/Extra/*" }]; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal(2, s.Volumes.Count); + } + + [Fact] + public async Task ScanLibrary_ExcludePattern_FlippedSlashes_Works() + { + const string testcase = "Exclude Pattern 1 - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**\\Extra\\*" }]; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal(2, s.Volumes.Count); + } + + [Fact] + public async Task ScanLibrary_MultipleRoots_MultipleScans_DataPersists_Forced() + { + const string testcase = "Multiple Roots - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = + Path.Join( + Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"), + testcase.Replace(".json", string.Empty)); + library.Folders = + [ + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 1")}, + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 2")} + ]; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + var s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(2, s.Volumes.Count); + var s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + + // Make a change (copy a file into only 1 root) + var root1PlushFolder = Path.Join(testDirectoryPath, "Root 1/Antarctic Press/Plush"); + File.Copy(Path.Join(root1PlushFolder, "Plush v02.cbz"), Path.Join(root1PlushFolder, "Plush v03.cbz")); + + // Rescan to ensure nothing changes yet again + await scanner.ScanLibrary(library.Id, true); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.Equal(2, postLib.Series.Count); + s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(3, s.Volumes.Count); + s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + } + + /// + /// Regression bug appeared where multi-root and one root gets a new file, on next scan of library, + /// the series in the other root are deleted. (This is actually failing because the file in Root 1 isn't being detected) + /// + [Fact] + public async Task ScanLibrary_MultipleRoots_MultipleScans_DataPersists_NonForced() + { + const string testcase = "Multiple Roots - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = + Path.Join( + Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"), + testcase.Replace(".json", string.Empty)); + library.Folders = + [ + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 1")}, + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 2")} + ]; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + var s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(2, s.Volumes.Count); + var s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + + // Make a change (copy a file into only 1 root) + var root1PlushFolder = Path.Join(testDirectoryPath, "Root 1/Antarctic Press/Plush"); + File.Copy(Path.Join(root1PlushFolder, "Plush v02.cbz"), Path.Join(root1PlushFolder, "Plush v03.cbz")); + + // Emulate time passage by updating lastFolderScan to be a min in the past + await SetLastScannedInThePast(s); + + // Rescan to ensure nothing changes yet again + await scanner.ScanLibrary(library.Id, false); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.Equal(2, postLib.Series.Count); + s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(3, s.Volumes.Count); + s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + } + + [Fact] + public async Task ScanLibrary_AlternatingRemoval_IssueReplication() + { + // https://github.com/Kareadita/Kavita/issues/3476#issuecomment-2661635558 + const string testcase = "Alternating Removal - Manga.json"; + + // Setup: Generate test library + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/ScannerService/ScanTests", + testcase.Replace(".json", string.Empty)); + + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + + // First Scan: Everything should be added + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Second Scan: Remove Root 2, expect Accel to be removed + library.Folders = [new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }]; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + // Emulate time passage by updating lastFolderScan to be a min in the past + foreach (var s in postLib.Series) + { + s.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(1)); + Context.Series.Update(s); } + await Context.SaveChangesAsync(); - AddRandomTag("Genre", genres); - comicInfo.AppendLine(""); + await scanner.ScanLibrary(library.Id); + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); - return comicInfo.ToString(); + Assert.DoesNotContain(postLib.Series, s => s.Name == "Accel"); // Ensure Accel is gone + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Third Scan: Re-add Root 2, Accel should come back + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + // Emulate time passage by updating lastFolderScan to be a min in the past + foreach (var s in postLib.Series) + { + s.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(1)); + Context.Series.Update(s); + } + await Context.SaveChangesAsync(); + + await scanner.ScanLibrary(library.Id); + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); // Accel should be back + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Emulate time passage by updating lastFolderScan to be a min in the past + await SetAllSeriesLastScannedInThePast(postLib); + + // Fourth Scan: Run again to check stability (should not remove Accel) + await scanner.ScanLibrary(library.Id); + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + } + + [Fact] + public async Task ScanLibrary_DeleteSeriesInUI_ComeBack() + { + const string testcase = "Delete Series In UI - Manga.json"; + + // Setup: Generate test library + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/ScannerService/ScanTests", + testcase.Replace(".json", string.Empty)); + + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + + // First Scan: Everything should be added + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Second Scan: Delete the Series + library.Series = []; + await UnitOfWork.CommitAsync(); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Empty(postLib.Series); + + await scanner.ScanLibrary(library.Id); + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); // Ensure Accel is gone + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + } + + [Fact] + public async Task SubFolders_NoRemovals_ChangesFound() + { + const string testcase = "Subfolders always scanning all series changes - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + Assert.Equal(3, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + var frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Single(frieren.Volumes); + Assert.Equal(2, frieren.Volumes.Sum(v => v.Chapters.Count)); + + var executionerAndHerWayOfLife = postLib.Series.First(x => x.Name == "The Executioner and Her Way of Life"); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Sum(v => v.Chapters.Count)); + + await SetAllSeriesLastScannedInThePast(postLib); + + // Add a new chapter to a volume of the series, and scan. Validate that no chapters were lost, and the new + // chapter was added + var executionerCopyDir = Path.Join(Path.Join(testDirectoryPath, "The Executioner and Her Way of Life"), + "The Executioner and Her Way of Life Vol. 1"); + File.Copy(Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0001.cbz"), + Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0002.cbz")); + + await scanner.ScanLibrary(library.Id); + await UnitOfWork.CommitAsync(); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + Assert.Equal(3, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Single(frieren.Volumes); + Assert.Equal(2, frieren.Volumes.Sum(v => v.Chapters.Count)); + + executionerAndHerWayOfLife = postLib.Series.First(x => x.Name == "The Executioner and Her Way of Life"); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count); + Assert.Equal(3, executionerAndHerWayOfLife.Volumes.Sum(v => v.Chapters.Count)); // Incremented by 1 + } + + [Fact] + public async Task RemovalPickedUp_NoOtherChanges() + { + const string testcase = "Series removed when no other changes are made - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + + var executionerCopyDir = Path.Join(testDirectoryPath, "The Executioner and Her Way of Life"); + Directory.Delete(executionerCopyDir, true); + + await scanner.ScanLibrary(library.Id); + await UnitOfWork.CommitAsync(); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Single(postLib.Series, s => s.Name == "Spice and Wolf"); + Assert.Equal(2, postLib.Series.First().Volumes.Count); + } + + [Fact] + public async Task SubFoldersNoSubFolders_CorrectPickupAfterAdd() + { + // This test case is used in multiple tests and can result in conflict if not separated + const string testcase = "Subfolders and files at root (2) - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + await SetLastScannedInThePast(spiceAndWolf); + + // Add volume to Spice and Wolf series directory + var spiceAndWolfDir = Path.Join(testDirectoryPath, "Spice and Wolf"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 1.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 4.cbz")); + + await scanner.ScanLibrary(library.Id); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(4, spiceAndWolf.Volumes.Count); + Assert.Equal(5, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + await SetLastScannedInThePast(spiceAndWolf); + + // Add file in subfolder + spiceAndWolfDir = Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0012.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0013.cbz")); + + await scanner.ScanLibrary(library.Id); + + postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(4, spiceAndWolf.Volumes.Count); + Assert.Equal(6, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + } + + + /// + /// Ensure when Kavita scans, the sort order of chapters is correct + /// + [Fact] + public async Task ScanLibrary_SortOrderWorks() + { + const string testcase = "Sort Order - Manga.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + + // Get the loose leaf volume and confirm each chapter aligns with expectation of Sort Order + var series = postLib.Series.First(); + Assert.NotNull(series); + + var volume = series.Volumes.FirstOrDefault(); + Assert.NotNull(volume); + + var sortedChapters = volume.Chapters.OrderBy(c => c.SortOrder).ToList(); + Assert.True(sortedChapters[0].SortOrder.Is(1f)); + Assert.True(sortedChapters[1].SortOrder.Is(4f)); + Assert.True(sortedChapters[2].SortOrder.Is(5f)); + } + + + [Fact] + public async Task ScanLibrary_MetadataDisabled_NoOverrides() + { + const string testcase = "Series with Localized No Metadata - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Immoral Guild v01.cbz", new ComicInfo() + { + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + // Disable metadata + library.EnableMetadata = false; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + // Validate that there are 2 series + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + + Assert.Contains(postLib.Series, x => x.Name == "Immoral Guild"); + Assert.Contains(postLib.Series, x => x.Name == "Futoku No Guild"); + } + + [Fact] + public async Task ScanLibrary_SortName_NoPrefix() + { + const string testcase = "Series with Prefix - Book.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + library.RemovePrefixForSortName = true; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Equal(1, postLib.Series.Count); + + Assert.Equal("The Avengers", postLib.Series.First().Name); + Assert.Equal("Avengers", postLib.Series.First().SortName); } } diff --git a/API.Tests/Services/ScrobblingServiceTests.cs b/API.Tests/Services/ScrobblingServiceTests.cs index d460ee4e5..9245c8ecd 100644 --- a/API.Tests/Services/ScrobblingServiceTests.cs +++ b/API.Tests/Services/ScrobblingServiceTests.cs @@ -1,11 +1,619 @@ -using API.Services.Plus; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.DTOs.Scrobbling; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Scrobble; +using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; +using API.SignalR; +using Kavita.Common; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace API.Tests.Services; #nullable enable -public class ScrobblingServiceTests +public class ScrobblingServiceTests : AbstractDbTest { + private const int ChapterPages = 100; + + /// + /// { + /// "Issuer": "Issuer", + /// "Issued At": "2025-06-15T21:01:57.615Z", + /// "Expiration": "2200-06-15T21:01:57.615Z" + /// } + /// + /// Our UnitTests will fail in 2200 :( + private const string ValidJwtToken = + "eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJleHAiOjcyNzI0NTAxMTcsImlhdCI6MTc1MDAyMTMxN30.zADmcGq_BfxbcV8vy4xw5Cbzn4COkmVINxgqpuL17Ng"; + + private readonly ScrobblingService _service; + private readonly ILicenseService _licenseService; + private readonly ILocalizationService _localizationService; + private readonly ILogger _logger; + private readonly IEmailService _emailService; + private readonly IKavitaPlusApiService _kavitaPlusApiService; + /// + /// IReaderService, without the ScrobblingService injected + /// + private readonly IReaderService _readerService; + /// + /// IReaderService, with the _service injected + /// + private readonly IReaderService _hookedUpReaderService; + + public ScrobblingServiceTests() + { + _licenseService = Substitute.For(); + _localizationService = Substitute.For(); + _logger = Substitute.For>(); + _emailService = Substitute.For(); + _kavitaPlusApiService = Substitute.For(); + + _service = new ScrobblingService(UnitOfWork, Substitute.For(), _logger, _licenseService, + _localizationService, _emailService, _kavitaPlusApiService); + + _readerService = new ReaderService(UnitOfWork, + Substitute.For>(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For()); // Do not use the actual one + + _hookedUpReaderService = new ReaderService(UnitOfWork, + Substitute.For>(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + _service); + } + + protected override async Task ResetDb() + { + Context.ScrobbleEvent.RemoveRange(Context.ScrobbleEvent.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); + Context.AppUser.RemoveRange(Context.AppUser.ToList()); + + await UnitOfWork.CommitAsync(); + } + + private async Task SeedData() + { + var series = new SeriesBuilder("Test Series") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithVolume(new VolumeBuilder("Volume 1") + .WithChapters([ + new ChapterBuilder("1") + .WithPages(ChapterPages) + .Build(), + new ChapterBuilder("2") + .WithPages(ChapterPages) + .Build(), + new ChapterBuilder("3") + .WithPages(ChapterPages) + .Build()]) + .Build()) + .WithVolume(new VolumeBuilder("Volume 2") + .WithChapters([ + new ChapterBuilder("4") + .WithPages(ChapterPages) + .Build(), + new ChapterBuilder("5") + .WithPages(ChapterPages) + .Build(), + new ChapterBuilder("6") + .WithPages(ChapterPages) + .Build()]) + .Build()) + .Build(); + + var library = new LibraryBuilder("Test Library", LibraryType.Manga) + .WithAllowScrobbling(true) + .WithSeries(series) + .Build(); + + + Context.Library.Add(library); + + var user = new AppUserBuilder("testuser", "testuser") + //.WithPreferences(new UserPreferencesBuilder().WithAniListScrobblingEnabled(true).Build()) + .Build(); + + user.UserPreferences.AniListScrobblingEnabled = true; + + UnitOfWork.UserRepository.Add(user); + + await UnitOfWork.CommitAsync(); + } + + private async Task CreateScrobbleEvent(int? seriesId = null) + { + var evt = new ScrobbleEvent + { + ScrobbleEventType = ScrobbleEventType.ChapterRead, + Format = PlusMediaFormat.Manga, + SeriesId = seriesId ?? 0, + LibraryId = 0, + AppUserId = 0, + }; + + if (seriesId != null) + { + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId.Value); + if (series != null) evt.Series = series; + } + + return evt; + } + + + #region K+ API Request Tests + + [Fact] + public async Task PostScrobbleUpdate_AuthErrors() + { + _kavitaPlusApiService.PostScrobbleUpdate(null!, "") + .ReturnsForAnyArgs(new ScrobbleResponseDto() + { + ErrorMessage = "Unauthorized" + }); + + var evt = await CreateScrobbleEvent(); + await Assert.ThrowsAsync(async () => + { + await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt); + }); + Assert.True(evt.IsErrored); + Assert.Equal("Kavita+ subscription no longer active", evt.ErrorDetails); + } + + [Fact] + public async Task PostScrobbleUpdate_UnknownSeriesLoggedAsError() + { + _kavitaPlusApiService.PostScrobbleUpdate(null!, "") + .ReturnsForAnyArgs(new ScrobbleResponseDto() + { + ErrorMessage = "Unknown Series" + }); + + await SeedData(); + var evt = await CreateScrobbleEvent(1); + + await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt); + await UnitOfWork.CommitAsync(); + Assert.True(evt.IsErrored); + + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); + Assert.True(series.IsBlacklisted); + + var errors = await UnitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(1); + Assert.Single(errors); + Assert.Equal("Series cannot be matched for Scrobbling", errors.First().Comment); + Assert.Equal(series.Id, errors.First().SeriesId); + } + + [Fact] + public async Task PostScrobbleUpdate_InvalidAccessToken() + { + _kavitaPlusApiService.PostScrobbleUpdate(null!, "") + .ReturnsForAnyArgs(new ScrobbleResponseDto() + { + ErrorMessage = "Access token is invalid" + }); + + var evt = await CreateScrobbleEvent(); + + await Assert.ThrowsAsync(async () => + { + await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt); + }); + + Assert.True(evt.IsErrored); + Assert.Equal("Access Token needs to be rotated to continue scrobbling", evt.ErrorDetails); + } + + #endregion + + #region K+ API Request data tests + + [Fact] + public async Task ProcessReadEvents_CreatesNoEventsWhenNoProgress() + { + await ResetDb(); + await SeedData(); + + // Set Returns + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + _kavitaPlusApiService.GetRateLimit(Arg.Any(), Arg.Any()) + .Returns(100); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1); + Assert.NotNull(user); + + // Ensure CanProcessScrobbleEvent returns true + user.AniListAccessToken = ValidJwtToken; + UnitOfWork.UserRepository.Update(user); + await UnitOfWork.CommitAsync(); + + var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4); + Assert.NotNull(chapter); + + var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters); + Assert.NotNull(volume); + + // Call Scrobble without having any progress + await _service.ScrobbleReadingUpdate(1, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + } + + [Fact] + public async Task ProcessReadEvents_UpdateVolumeAndChapterData() + { + await ResetDb(); + await SeedData(); + + // Set Returns + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + _kavitaPlusApiService.GetRateLimit(Arg.Any(), Arg.Any()) + .Returns(100); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1); + Assert.NotNull(user); + + // Ensure CanProcessScrobbleEvent returns true + user.AniListAccessToken = ValidJwtToken; + UnitOfWork.UserRepository.Update(user); + await UnitOfWork.CommitAsync(); + + var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4); + Assert.NotNull(chapter); + + var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters); + Assert.NotNull(volume); + + // Mark something as read to trigger event creation + await _readerService.MarkChaptersAsRead(user, 1, new List() {volume.Chapters[0]}); + await UnitOfWork.CommitAsync(); + + // Call Scrobble while having some progress + await _service.ScrobbleReadingUpdate(user.Id, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Single(events); + + // Give it some (more) read progress + await _readerService.MarkChaptersAsRead(user, 1, volume.Chapters); + await _readerService.MarkChaptersAsRead(user, 1, [chapter]); + await UnitOfWork.CommitAsync(); + + await _service.ProcessUpdatesSinceLastSync(); + + await _kavitaPlusApiService.Received(1).PostScrobbleUpdate( + Arg.Is(data => + data.ChapterNumber == (int)chapter.MaxNumber && + data.VolumeNumber == (int)volume.MaxNumber + ), + Arg.Any()); + } + + #endregion + + #region Scrobble Reading Update Tests + + [Fact] + public async Task ScrobbleReadingUpdate_IgnoreNoLicense() + { + await ResetDb(); + await SeedData(); + + _licenseService.HasActiveLicense().Returns(false); + + await _service.ScrobbleReadingUpdate(1, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + } + + [Fact] + public async Task ScrobbleReadingUpdate_RemoveWhenNoProgress() + { + await ResetDb(); + await SeedData(); + + _licenseService.HasActiveLicense().Returns(true); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1); + Assert.NotNull(user); + + var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters); + Assert.NotNull(volume); + + await _readerService.MarkChaptersAsRead(user, 1, new List() {volume.Chapters[0]}); + await UnitOfWork.CommitAsync(); + + await _service.ScrobbleReadingUpdate(1, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Single(events); + + var readEvent = events.First(); + Assert.False(readEvent.IsProcessed); + + await _hookedUpReaderService.MarkSeriesAsUnread(user, 1); + await UnitOfWork.CommitAsync(); + + // Existing event is deleted + await _service.ScrobbleReadingUpdate(1, 1); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + + await _hookedUpReaderService.MarkSeriesAsUnread(user, 1); + await UnitOfWork.CommitAsync(); + + // No new events are added + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + } + + [Fact] + public async Task ScrobbleReadingUpdate_UpdateExistingNotIsProcessed() + { + await ResetDb(); + await SeedData(); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1); + Assert.NotNull(user); + + var chapter1 = await UnitOfWork.ChapterRepository.GetChapterAsync(1); + var chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2); + var chapter3 = await UnitOfWork.ChapterRepository.GetChapterAsync(3); + Assert.NotNull(chapter1); + Assert.NotNull(chapter2); + Assert.NotNull(chapter3); + + _licenseService.HasActiveLicense().Returns(true); + + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + + + await _readerService.MarkChaptersAsRead(user, 1, [chapter1]); + await UnitOfWork.CommitAsync(); + + // Scrobble update + await _service.ScrobbleReadingUpdate(1, 1); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Single(events); + + var readEvent = events[0]; + Assert.False(readEvent.IsProcessed); + Assert.Equal(1, readEvent.ChapterNumber); + + // Mark as processed + readEvent.IsProcessed = true; + await UnitOfWork.CommitAsync(); + + await _readerService.MarkChaptersAsRead(user, 1, [chapter2]); + await UnitOfWork.CommitAsync(); + + // Scrobble update + await _service.ScrobbleReadingUpdate(1, 1); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Equal(2, events.Count); + Assert.Single(events.Where(e => e.IsProcessed).ToList()); + Assert.Single(events.Where(e => !e.IsProcessed).ToList()); + + // Should update the existing non processed event + await _readerService.MarkChaptersAsRead(user, 1, [chapter3]); + await UnitOfWork.CommitAsync(); + + // Scrobble update + await _service.ScrobbleReadingUpdate(1, 1); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Equal(2, events.Count); + Assert.Single(events.Where(e => e.IsProcessed).ToList()); + Assert.Single(events.Where(e => !e.IsProcessed).ToList()); + } + + #endregion + + #region ScrobbleWantToReadUpdate Tests + + [Fact] + public async Task ScrobbleWantToReadUpdate_NoExistingEvents_WantToRead_ShouldCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // Act + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + Assert.Single(events); + Assert.Equal(ScrobbleEventType.AddWantToRead, events[0].ScrobbleEventType); + Assert.Equal(userId, events[0].AppUserId); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_NoExistingEvents_RemoveWantToRead_ShouldCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // Act + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + Assert.Single(events); + Assert.Equal(ScrobbleEventType.RemoveWantToRead, events[0].ScrobbleEventType); + Assert.Equal(userId, events[0].AppUserId); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingWantToReadEvent_WantToRead_ShouldNotCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create an event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Act - Try to create the same event again + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.All(events, e => Assert.Equal(ScrobbleEventType.AddWantToRead, e.ScrobbleEventType)); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingWantToReadEvent_RemoveWantToRead_ShouldAddRemoveEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create a want-to-read event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Act - Now remove from want-to-read + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.Contains(events, e => e.ScrobbleEventType == ScrobbleEventType.RemoveWantToRead); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingRemoveWantToReadEvent_RemoveWantToRead_ShouldNotCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create a remove-from-want-to-read event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Act - Try to create the same event again + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.All(events, e => Assert.Equal(ScrobbleEventType.RemoveWantToRead, e.ScrobbleEventType)); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingRemoveWantToReadEvent_WantToRead_ShouldAddWantToReadEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create a remove-from-want-to-read event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Act - Now add to want-to-read + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Assert + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.Contains(events, e => e.ScrobbleEventType == ScrobbleEventType.AddWantToRead); + } + + #endregion + + #region Scrobble Rating Update Test + + [Fact] + public async Task ScrobbleRatingUpdate_IgnoreNoLicense() + { + await ResetDb(); + await SeedData(); + + _licenseService.HasActiveLicense().Returns(false); + + await _service.ScrobbleRatingUpdate(1, 1, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Empty(events); + } + + [Fact] + public async Task ScrobbleRatingUpdate_UpdateExistingNotIsProcessed() + { + await ResetDb(); + await SeedData(); + + _licenseService.HasActiveLicense().Returns(true); + + var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1); + Assert.NotNull(user); + + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); + + await _service.ScrobbleRatingUpdate(user.Id, series.Id, 1); + var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Single(events); + Assert.Equal(1, events.First().Rating); + + // Mark as processed + events.First().IsProcessed = true; + await UnitOfWork.CommitAsync(); + + await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Equal(2, events.Count); + Assert.Single(events, evt => evt.IsProcessed); + Assert.Single(events, evt => !evt.IsProcessed); + + await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5); + events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1); + Assert.Single(events, evt => !evt.IsProcessed); + Assert.Equal(5, events.First(evt => !evt.IsProcessed).Rating); + + } + + #endregion + [Theory] [InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)] [InlineData("https://anilist.co/manga/30105", 30105)] diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 147fe96db..55babf815 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO.Abstractions; using System.Linq; using System.Threading.Tasks; @@ -7,10 +8,12 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; using API.Helpers.Builders; using API.Services; @@ -54,22 +57,23 @@ public class SeriesServiceTests : AbstractDbTest var locService = new LocalizationService(ds, new MockHostingEnvironment(), Substitute.For(), Substitute.For()); - _seriesService = new SeriesService(_unitOfWork, Substitute.For(), + _seriesService = new SeriesService(UnitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), - Substitute.For(), locService); + Substitute.For(), locService, Substitute.For()); } + #region Setup protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); - _context.AppUserRating.RemoveRange(_context.AppUserRating.ToList()); - _context.Genre.RemoveRange(_context.Genre.ToList()); - _context.CollectionTag.RemoveRange(_context.CollectionTag.ToList()); - _context.Person.RemoveRange(_context.Person.ToList()); - _context.Library.RemoveRange(_context.Library.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); + Context.AppUserRating.RemoveRange(Context.AppUserRating.ToList()); + Context.Genre.RemoveRange(Context.Genre.ToList()); + Context.CollectionTag.RemoveRange(Context.CollectionTag.ToList()); + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } private static UpdateRelatedSeriesDto CreateRelationsDto(Series series) @@ -102,7 +106,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -123,7 +127,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var expectedRanges = new[] {"Omake", "Something SP02"}; @@ -138,7 +142,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -159,7 +163,7 @@ public class SeriesServiceTests : AbstractDbTest .Build() ); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Chapters); @@ -175,7 +179,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -193,7 +197,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Chapters); @@ -209,7 +213,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) @@ -226,7 +230,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Chapters); @@ -245,7 +249,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -260,7 +264,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Volumes); @@ -274,7 +278,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -291,7 +295,7 @@ public class SeriesServiceTests : AbstractDbTest - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.NotEmpty(detail.Volumes); @@ -311,7 +315,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -329,7 +333,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); Assert.Equal("Volume 1", detail.Volumes.ElementAt(0).Name); @@ -346,7 +350,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -370,7 +374,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); @@ -397,7 +401,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Comic) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Comic) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -421,7 +425,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); @@ -447,7 +451,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.ComicVine) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.ComicVine) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -471,7 +475,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); @@ -497,7 +501,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -519,7 +523,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); @@ -545,7 +549,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.LightNovel) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.LightNovel) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -567,7 +571,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()) .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var detail = await _seriesService.GetSeriesDetail(1, 1); @@ -587,164 +591,6 @@ public class SeriesServiceTests : AbstractDbTest - #endregion - - - #region UpdateRating - - [Fact] - public async Task UpdateRating_ShouldSetRating() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) - .WithSeries(new SeriesBuilder("Test") - - .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) - .Build()) - .Build()) - .Build()); - - - await _context.SaveChangesAsync(); - - - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - - JobStorage.Current = new InMemoryStorage(); - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto - { - SeriesId = 1, - UserRating = 3, - }); - - Assert.True(result); - - var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))! - .Ratings; - Assert.NotEmpty(ratings); - Assert.Equal(3, ratings.First().Rating); - } - - [Fact] - public async Task UpdateRating_ShouldUpdateExistingRating() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) - .WithSeries(new SeriesBuilder("Test") - - .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) - .Build()) - .Build()) - .Build()); - - - await _context.SaveChangesAsync(); - - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto - { - SeriesId = 1, - UserRating = 3, - }); - - Assert.True(result); - - JobStorage.Current = new InMemoryStorage(); - var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings)) - .Ratings; - Assert.NotEmpty(ratings); - Assert.Equal(3, ratings.First().Rating); - - // Update the DB again - - var result2 = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto - { - SeriesId = 1, - UserRating = 5, - }); - - Assert.True(result2); - - var ratings2 = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings)) - .Ratings; - Assert.NotEmpty(ratings2); - Assert.True(ratings2.Count == 1); - Assert.Equal(5, ratings2.First().Rating); - } - - [Fact] - public async Task UpdateRating_ShouldClampRatingAt5() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) - .WithSeries(new SeriesBuilder("Test") - - .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) - .Build()) - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto - { - SeriesId = 1, - UserRating = 10, - }); - - Assert.True(result); - - JobStorage.Current = new InMemoryStorage(); - var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", - AppUserIncludes.Ratings)!) - .Ratings; - Assert.NotEmpty(ratings); - Assert.Equal(5, ratings.First().Rating); - } - - [Fact] - public async Task UpdateRating_ShouldReturnFalseWhenSeriesDoesntExist() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) - .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) - .WithSeries(new SeriesBuilder("Test") - - .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) - .Build()) - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto - { - SeriesId = 2, - UserRating = 5, - }); - - Assert.False(result); - - var ratings = user.Ratings; - Assert.Empty(ratings); - } - #endregion #region UpdateSeriesMetadata @@ -757,8 +603,8 @@ public class SeriesServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - _context.Series.Add(s); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -772,7 +618,7 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); @@ -789,10 +635,10 @@ public class SeriesServiceTests : AbstractDbTest var g = new GenreBuilder("Existing Genre").Build(); s.Metadata.Genres = new List {g}; - _context.Series.Add(s); + Context.Series.Add(s); - _context.Genre.Add(g); - await _context.SaveChangesAsync(); + Context.Genre.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -806,7 +652,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.True(series.Metadata.Genres.Select(g1 => g1.Title).All(g2 => g2 == "New Genre".SentenceCase())); Assert.False(series.Metadata.GenresLocked); // GenreLocked is false unless the UI Explicitly says it should be locked @@ -817,7 +664,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); var g = new PersonBuilder("Existing Person").Build(); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var s = new SeriesBuilder("Test") .WithMetadata(new SeriesMetadataBuilder() @@ -827,10 +674,10 @@ public class SeriesServiceTests : AbstractDbTest s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - _context.Series.Add(s); + Context.Series.Add(s); - _context.Person.Add(g); - await _context.SaveChangesAsync(); + Context.Person.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -844,7 +691,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.True(series.Metadata.People.Select(g => g.Person.Name).All(personName => personName == "Existing Person")); Assert.False(series.Metadata.PublisherLocked); // PublisherLocked is false unless the UI Explicitly says it should be locked @@ -866,10 +714,10 @@ public class SeriesServiceTests : AbstractDbTest new SeriesMetadataPeople() {Person = new PersonBuilder("Existing Publisher 2").Build(), Role = PersonRole.Publisher} }; - _context.Series.Add(s); + Context.Series.Add(s); - _context.Person.Add(g); - await _context.SaveChangesAsync(); + Context.Person.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -884,7 +732,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.True(series.Metadata.People.Select(g => g.Person.Name).All(personName => personName == "Existing Person")); Assert.True(series.Metadata.PublisherLocked); @@ -915,9 +764,9 @@ public class SeriesServiceTests : AbstractDbTest new SeriesMetadataPeople { Person = new PersonBuilder("Existing Publisher 2").Build(), Role = PersonRole.Publisher } }; - _context.Series.Add(series); - _context.Person.Add(existingPerson); - await _context.SaveChangesAsync(); + 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 @@ -934,7 +783,7 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); // Reload the series from the database - var updatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id); + 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 @@ -956,10 +805,10 @@ public class SeriesServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); var g = new PersonBuilder("Existing Person").Build(); - _context.Series.Add(s); + Context.Series.Add(s); - _context.Person.Add(g); - await _context.SaveChangesAsync(); + Context.Person.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -973,11 +822,65 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.False(series.Metadata.People.Any()); } + /// + /// This emulates the UI operations wrt to locking + /// + [Fact] + public async Task UpdateSeriesMetadata_ShouldRemoveExistingPerson_AfterAdding() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); + var g = new PersonBuilder("Existing Person").Build(); + Context.Series.Add(s); + + Context.Person.Add(g); + await Context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = 1, + Publishers = new List() {new PersonDto() {Name = "Test"}}, + PublisherLocked = true + }, + + }); + + Assert.True(success); + + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); + Assert.NotNull(series.Metadata); + Assert.True(series.Metadata.People.Count != 0); + Assert.True(series.Metadata.PublisherLocked); + + + success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = 1, + Publishers = new List(), + PublisherLocked = false + }, + + }); + + Assert.True(success); + Assert.Empty(series.Metadata.People); + Assert.False(series.Metadata.PublisherLocked); + } + [Fact] public async Task UpdateSeriesMetadata_ShouldLockIfTold() { @@ -989,10 +892,10 @@ public class SeriesServiceTests : AbstractDbTest var g = new GenreBuilder("Existing Genre").Build(); s.Metadata.Genres = new List {g}; s.Metadata.GenresLocked = true; - _context.Series.Add(s); + Context.Series.Add(s); - _context.Genre.Add(g); - await _context.SaveChangesAsync(); + Context.Genre.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1007,7 +910,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.True(series.Metadata.Genres.Select(g => g.Title).All(g => g == "Existing Genre".SentenceCase())); Assert.True(series.Metadata.GenresLocked); @@ -1021,8 +925,8 @@ public class SeriesServiceTests : AbstractDbTest .WithMetadata(new SeriesMetadataBuilder().Build()) .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - _context.Series.Add(s); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1036,7 +940,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Equal(0, series.Metadata.ReleaseYear); Assert.False(series.Metadata.ReleaseYearLocked); @@ -1054,8 +959,8 @@ public class SeriesServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); - _context.Series.Add(s); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1068,7 +973,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); 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. @@ -1086,9 +992,9 @@ public class SeriesServiceTests : AbstractDbTest var g = new GenreBuilder("Existing Genre").Build(); s.Metadata.Genres = new List { g }; - _context.Series.Add(s); - _context.Genre.Add(g); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + Context.Genre.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1101,7 +1007,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); 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)); @@ -1119,9 +1026,9 @@ public class SeriesServiceTests : AbstractDbTest var g = new GenreBuilder("Existing Genre").Build(); s.Metadata.Genres = new List { g }; - _context.Series.Add(s); - _context.Genre.Add(g); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + Context.Genre.Add(g); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1134,7 +1041,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Empty(series.Metadata.Genres); } @@ -1151,8 +1059,8 @@ public class SeriesServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); - _context.Series.Add(s); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1165,7 +1073,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(t => t.Title)); } @@ -1182,9 +1091,9 @@ public class SeriesServiceTests : AbstractDbTest var t = new TagBuilder("Existing Tag").Build(); s.Metadata.Tags = new List { t }; - _context.Series.Add(s); - _context.Tag.Add(t); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + Context.Tag.Add(t); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1197,7 +1106,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); 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)); @@ -1215,9 +1125,9 @@ public class SeriesServiceTests : AbstractDbTest var t = new TagBuilder("Existing Tag").Build(); s.Metadata.Tags = new List { t }; - _context.Series.Add(s); - _context.Tag.Add(t); - await _context.SaveChangesAsync(); + Context.Series.Add(s); + Context.Tag.Add(t); + await Context.SaveChangesAsync(); var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { @@ -1230,7 +1140,8 @@ public class SeriesServiceTests : AbstractDbTest Assert.True(success); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Empty(series.Metadata.Tags); } @@ -1361,12 +1272,12 @@ public class SeriesServiceTests : AbstractDbTest #endregion - #region SeriesRelation + #region Series Relation [Fact] public async Task UpdateRelatedSeries_ShouldAddAllRelations() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1385,9 +1296,9 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); @@ -1402,7 +1313,7 @@ public class SeriesServiceTests : AbstractDbTest public async Task UpdateRelatedSeries_ShouldAddPrequelWhenAddingSequel() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1420,15 +1331,16 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); - var series2 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series2 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Sequels.Add(2); await _seriesService.UpdateRelatedSeries(addRelationDto); Assert.NotNull(series1); + Assert.NotNull(series2); Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); Assert.Equal(1, series2.Relations.Single(s => s.TargetSeriesId == 1).TargetSeriesId); } @@ -1437,7 +1349,7 @@ public class SeriesServiceTests : AbstractDbTest public async Task UpdateRelatedSeries_DeleteAllRelations() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1456,9 +1368,9 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); @@ -1471,8 +1383,9 @@ public class SeriesServiceTests : AbstractDbTest // Remove relations var removeRelationDto = CreateRelationsDto(series1); await _seriesService.UpdateRelatedSeries(removeRelationDto); - Assert.Empty(series1.Relations.Where(s => s.TargetSeriesId == 1)); - Assert.Empty(series1.Relations.Where(s => s.TargetSeriesId == 2)); + Assert.NotNull(series1); + Assert.DoesNotContain(series1.Relations, s => s.TargetSeriesId == 1); + Assert.DoesNotContain(series1.Relations, s => s.TargetSeriesId == 2); } @@ -1480,7 +1393,7 @@ public class SeriesServiceTests : AbstractDbTest public async Task UpdateRelatedSeries_DeleteTargetSeries_ShouldSucceed() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1498,19 +1411,21 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); await _seriesService.UpdateRelatedSeries(addRelationDto); + + Assert.NotNull(series1); Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); - _context.Series.Remove(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2)); + Context.Series.Remove(await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2)); try { - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } catch (Exception) { @@ -1518,14 +1433,14 @@ public class SeriesServiceTests : AbstractDbTest } // Remove relations - Assert.Empty((await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related)).Relations); + Assert.Empty((await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related)).Relations); } [Fact] public async Task UpdateRelatedSeries_DeleteSourceSeries_ShouldSucceed() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1543,9 +1458,9 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); @@ -1553,12 +1468,12 @@ public class SeriesServiceTests : AbstractDbTest Assert.NotNull(series1); Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); - var seriesToRemove = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var seriesToRemove = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1); Assert.NotNull(seriesToRemove); - _context.Series.Remove(seriesToRemove); + Context.Series.Remove(seriesToRemove); try { - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } catch (Exception) { @@ -1566,14 +1481,14 @@ public class SeriesServiceTests : AbstractDbTest } // Remove relations - Assert.Empty((await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Related)).Relations); + Assert.Empty((await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Related)).Relations); } [Fact] public async Task UpdateRelatedSeries_ShouldNotAllowDuplicates() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1591,9 +1506,9 @@ public class SeriesServiceTests : AbstractDbTest } }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); var relation = new SeriesRelation { Series = series1, @@ -1618,7 +1533,7 @@ public class SeriesServiceTests : AbstractDbTest public async Task GetRelatedSeries_EditionPrequelSequel_ShouldNotHaveParent() { await ResetDb(); - _context.Library.Add(new Library + Context.Library.Add(new Library { AppUsers = new List { @@ -1638,8 +1553,8 @@ public class SeriesServiceTests : AbstractDbTest new SeriesBuilder("Test Series Adaption").Build(), } }); - await _context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + await Context.SaveChangesAsync(); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Editions.Add(2); @@ -1665,30 +1580,30 @@ public class SeriesServiceTests : AbstractDbTest .WithSeries(new SeriesBuilder("Test Series Sequels").Build()) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); - _context.Library.Add(lib); + Context.Library.Add(lib); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); addRelationDto.Sequels.Add(3); await _seriesService.UpdateRelatedSeries(addRelationDto); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(lib.Id); - _unitOfWork.LibraryRepository.Delete(library); + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(lib.Id); + UnitOfWork.LibraryRepository.Delete(library); try { - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); } catch (Exception) { Assert.False(true); } - Assert.Null(await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1)); + Assert.Null(await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1)); } [Fact] @@ -1709,7 +1624,7 @@ public class SeriesServiceTests : AbstractDbTest .WithSeries(new SeriesBuilder("Test Series Sequels").Build()) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); - _context.Library.Add(lib1); + Context.Library.Add(lib1); var lib2 = new LibraryBuilder("Test LIb 2", LibraryType.Book) .WithSeries(new SeriesBuilder("Test Series 2").Build()) @@ -1717,29 +1632,29 @@ public class SeriesServiceTests : AbstractDbTest .WithSeries(new SeriesBuilder("Test Series Prequels 3").Build())// TODO: Is this a bug .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); - _context.Library.Add(lib2); + Context.Library.Add(lib2); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); // Add relations var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(4); // cross library link await _seriesService.UpdateRelatedSeries(addRelationDto); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(lib1.Id, LibraryIncludes.Series); - _unitOfWork.LibraryRepository.Delete(library); + var library = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(lib1.Id, LibraryIncludes.Series); + UnitOfWork.LibraryRepository.Delete(library); try { - await _unitOfWork.CommitAsync(); + await UnitOfWork.CommitAsync(); } catch (Exception) { Assert.False(true); } - Assert.Null(await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1)); + Assert.Null(await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(1)); } #endregion @@ -1761,13 +1676,13 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty) .WithLocale("en") .Build()) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); Assert.Equal(expected, await _seriesService.FormatChapterName(1, libraryType, withHash)); } @@ -1932,18 +1847,18 @@ public class SeriesServiceTests : AbstractDbTest .WithSeries(new SeriesBuilder("Test Series Sequels").Build()) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); - _context.Library.Add(lib1); + Context.Library.Add(lib1); var lib2 = new LibraryBuilder("Test LIb 2", LibraryType.Book) .WithSeries(new SeriesBuilder("Test Series 2").Build()) .WithSeries(new SeriesBuilder("Test Series Prequels 2").Build()) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); - _context.Library.Add(lib2); + Context.Library.Add(lib2); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, + var series1 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related | SeriesIncludes.ExternalRatings); // Add relations var addRelationDto = CreateRelationsDto(series1); @@ -1989,12 +1904,12 @@ public class SeriesServiceTests : AbstractDbTest } }; - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); // Ensure we can delete the series Assert.True(await _seriesService.DeleteMultipleSeries(new[] {1, 2})); - Assert.Null(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1)); - Assert.Null(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2)); + Assert.Null(await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1)); + Assert.Null(await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(2)); } #endregion @@ -2006,7 +1921,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -2019,7 +1934,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber); @@ -2031,7 +1946,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") .WithPublicationStatus(PublicationStatus.Completed) @@ -2044,7 +1959,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber); @@ -2056,7 +1971,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -2068,7 +1983,7 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); Assert.NotNull(nextChapter); @@ -2080,9 +1995,9 @@ public class SeriesServiceTests : AbstractDbTest public async Task GetEstimatedChapterCreationDate_NextChapter_ChaptersMonthApart() { await ResetDb(); - var now = DateTime.UtcNow; + var now = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture); // 10/31/2024 can trigger an edge case bug - _context.Library.Add(new LibraryBuilder("Test LIb") + Context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") .WithPublicationStatus(PublicationStatus.OnGoing) @@ -2096,13 +2011,14 @@ public class SeriesServiceTests : AbstractDbTest .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); Assert.NotNull(nextChapter); Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber); Assert.Equal(5, nextChapter.ChapterNumber); Assert.NotNull(nextChapter.ExpectedDate); + var expected = now.AddMonths(4); Assert.Equal(expected.Month, nextChapter.ExpectedDate.Value.Month); Assert.True(nextChapter.ExpectedDate.Value.Day >= expected.Day - 1 || nextChapter.ExpectedDate.Value.Day <= expected.Day + 1); diff --git a/API.Tests/Services/SettingsServiceTests.cs b/API.Tests/Services/SettingsServiceTests.cs new file mode 100644 index 000000000..a3c6b67b8 --- /dev/null +++ b/API.Tests/Services/SettingsServiceTests.cs @@ -0,0 +1,292 @@ +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; +using API.Entities; +using API.Entities.Enums; +using API.Entities.MetadataMatching; +using API.Services; +using API.Services.Tasks.Scanner; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class SettingsServiceTests +{ + private readonly ISettingsService _settingsService; + private readonly IUnitOfWork _mockUnitOfWork; + + public SettingsServiceTests() + { + var ds = new DirectoryService(Substitute.For>(), new FileSystem()); + + _mockUnitOfWork = Substitute.For(); + _settingsService = new SettingsService(_mockUnitOfWork, ds, + Substitute.For(), Substitute.For(), + Substitute.For>()); + } + + #region UpdateMetadataSettings + + [Fact] + public async Task UpdateMetadataSettings_ShouldUpdateExistingSettings() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + Enabled = false, + EnableSummary = false, + EnableLocalizedName = false, + EnablePublicationStatus = false, + EnableRelationships = false, + EnablePeople = false, + EnableStartDate = false, + EnableGenres = false, + EnableTags = false, + FirstLastPeopleNaming = false, + EnableCoverImage = false, + AgeRatingMappings = new Dictionary(), + Blacklist = [], + Whitelist = [], + Overrides = [], + PersonRoles = [], + FieldMappings = [] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + Enabled = true, + EnableSummary = true, + EnableLocalizedName = true, + EnablePublicationStatus = true, + EnableRelationships = true, + EnablePeople = true, + EnableStartDate = true, + EnableGenres = true, + EnableTags = true, + FirstLastPeopleNaming = true, + EnableCoverImage = true, + AgeRatingMappings = new Dictionary { { "Adult", AgeRating.R18Plus } }, + Blacklist = ["blacklisted-tag"], + Whitelist = ["whitelisted-tag"], + Overrides = [MetadataSettingField.Summary], + PersonRoles = [PersonRole.Writer], + FieldMappings = + [ + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Genre, + DestinationType = MetadataFieldType.Tag, + SourceValue = "Action", + DestinationValue = "Fight", + ExcludeFromSource = true + } + ] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + // Verify properties were updated + Assert.True(existingSettings.Enabled); + Assert.True(existingSettings.EnableSummary); + Assert.True(existingSettings.EnableLocalizedName); + Assert.True(existingSettings.EnablePublicationStatus); + Assert.True(existingSettings.EnableRelationships); + Assert.True(existingSettings.EnablePeople); + Assert.True(existingSettings.EnableStartDate); + Assert.True(existingSettings.EnableGenres); + Assert.True(existingSettings.EnableTags); + Assert.True(existingSettings.FirstLastPeopleNaming); + Assert.True(existingSettings.EnableCoverImage); + + // Verify collections were updated + Assert.Single(existingSettings.AgeRatingMappings); + Assert.Equal(AgeRating.R18Plus, existingSettings.AgeRatingMappings["Adult"]); + + Assert.Single(existingSettings.Blacklist); + Assert.Equal("blacklisted-tag", existingSettings.Blacklist[0]); + + Assert.Single(existingSettings.Whitelist); + Assert.Equal("whitelisted-tag", existingSettings.Whitelist[0]); + + Assert.Single(existingSettings.Overrides); + Assert.Equal(MetadataSettingField.Summary, existingSettings.Overrides[0]); + + Assert.Single(existingSettings.PersonRoles); + Assert.Equal(PersonRole.Writer, existingSettings.PersonRoles[0]); + + Assert.Single(existingSettings.FieldMappings); + Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].SourceType); + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].DestinationType); + Assert.Equal("Action", existingSettings.FieldMappings[0].SourceValue); + Assert.Equal("Fight", existingSettings.FieldMappings[0].DestinationValue); + Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource); + } + + [Fact] + public async Task UpdateMetadataSettings_WithNullCollections_ShouldUseEmptyCollections() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + FieldMappings = [new MetadataFieldMapping {Id = 1, SourceValue = "OldValue"}] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + AgeRatingMappings = null, + Blacklist = null, + Whitelist = null, + Overrides = null, + PersonRoles = null, + FieldMappings = null + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + Assert.Empty(existingSettings.AgeRatingMappings); + Assert.Empty(existingSettings.Blacklist); + Assert.Empty(existingSettings.Whitelist); + Assert.Empty(existingSettings.Overrides); + Assert.Empty(existingSettings.PersonRoles); + + // Verify existing field mappings were cleared + settingsRepo.Received(1).RemoveRange(Arg.Any>()); + Assert.Empty(existingSettings.FieldMappings); + } + + [Fact] + public async Task UpdateMetadataSettings_WithFieldMappings_ShouldReplaceExistingMappings() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + FieldMappings = + [ + new MetadataFieldMapping + { + Id = 1, + SourceType = MetadataFieldType.Genre, + DestinationType = MetadataFieldType.Genre, + SourceValue = "OldValue", + DestinationValue = "OldDestination", + ExcludeFromSource = false + } + ] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + FieldMappings = + [ + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + DestinationType = MetadataFieldType.Genre, + SourceValue = "NewValue", + DestinationValue = "NewDestination", + ExcludeFromSource = true + }, + + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + DestinationType = MetadataFieldType.Tag, + SourceValue = "AnotherValue", + DestinationValue = "AnotherDestination", + ExcludeFromSource = false + } + ] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + // Verify existing field mappings were cleared and new ones added + settingsRepo.Received(1).RemoveRange(Arg.Any>()); + Assert.Equal(2, existingSettings.FieldMappings.Count); + + // Verify first mapping + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].SourceType); + Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].DestinationType); + Assert.Equal("NewValue", existingSettings.FieldMappings[0].SourceValue); + Assert.Equal("NewDestination", existingSettings.FieldMappings[0].DestinationValue); + Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource); + + // Verify second mapping + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].SourceType); + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].DestinationType); + Assert.Equal("AnotherValue", existingSettings.FieldMappings[1].SourceValue); + Assert.Equal("AnotherDestination", existingSettings.FieldMappings[1].DestinationValue); + Assert.False(existingSettings.FieldMappings[1].ExcludeFromSource); + } + + [Fact] + public async Task UpdateMetadataSettings_WithBlacklistWhitelist_ShouldNormalizeAndDeduplicateEntries() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + Blacklist = [], + Whitelist = [] + }; + + // We need to mock the repository and provide a custom implementation for ToNormalized + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + // Include duplicates with different casing and whitespace + Blacklist = ["tag1", "Tag1", " tag2 ", "", " ", "tag3"], + Whitelist = ["allowed1", "Allowed1", " allowed2 ", "", "allowed3"] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + Assert.Equal(3, existingSettings.Blacklist.Count); + Assert.Equal(3, existingSettings.Whitelist.Count); + } + + #endregion +} diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs index 463d49df4..3893af1fb 100644 --- a/API.Tests/Services/SiteThemeServiceTests.cs +++ b/API.Tests/Services/SiteThemeServiceTests.cs @@ -31,24 +31,24 @@ public abstract class SiteThemeServiceTest : AbstractDbTest protected override async Task ResetDb() { - _context.SiteTheme.RemoveRange(_context.SiteTheme); - await _context.SaveChangesAsync(); + Context.SiteTheme.RemoveRange(Context.SiteTheme); + await Context.SaveChangesAsync(); // Recreate defaults - await Seed.SeedThemes(_context); + await Seed.SeedThemes(Context); } [Fact] public async Task UpdateDefault_ShouldThrowOnInvalidId() { await ResetDb(); - _testOutputHelper.WriteLine($"[UpdateDefault_ShouldThrowOnInvalidId] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); + _testOutputHelper.WriteLine($"[UpdateDefault_ShouldThrowOnInvalidId] All Themes: {(await UnitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + var siteThemeService = new ThemeService(ds, UnitOfWork, _messageHub, Substitute.For(), Substitute.For>(), Substitute.For()); - _context.SiteTheme.Add(new SiteTheme() + Context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), @@ -56,7 +56,7 @@ public abstract class SiteThemeServiceTest : AbstractDbTest FileName = "custom.css", IsDefault = false }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var ex = await Assert.ThrowsAsync(() => siteThemeService.UpdateDefault(10)); Assert.Equal("Theme file missing or invalid", ex.Message); @@ -68,14 +68,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest public async Task GetContent_ShouldReturnContent() { await ResetDb(); - _testOutputHelper.WriteLine($"[GetContent_ShouldReturnContent] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); + _testOutputHelper.WriteLine($"[GetContent_ShouldReturnContent] All Themes: {(await UnitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + var siteThemeService = new ThemeService(ds, UnitOfWork, _messageHub, Substitute.For(), Substitute.For>(), Substitute.For()); - _context.SiteTheme.Add(new SiteTheme() + Context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), @@ -83,9 +83,9 @@ public abstract class SiteThemeServiceTest : AbstractDbTest FileName = "custom.css", IsDefault = false }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var content = await siteThemeService.GetContent((await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id); + var content = await siteThemeService.GetContent((await UnitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id); Assert.NotNull(content); Assert.NotEmpty(content); Assert.Equal("123", content); @@ -95,14 +95,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest public async Task UpdateDefault_ShouldHaveOneDefault() { await ResetDb(); - _testOutputHelper.WriteLine($"[UpdateDefault_ShouldHaveOneDefault] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); + _testOutputHelper.WriteLine($"[UpdateDefault_ShouldHaveOneDefault] All Themes: {(await UnitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + var siteThemeService = new ThemeService(ds, UnitOfWork, _messageHub, Substitute.For(), Substitute.For>(), Substitute.For()); - _context.SiteTheme.Add(new SiteTheme() + Context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), @@ -110,16 +110,16 @@ public abstract class SiteThemeServiceTest : AbstractDbTest FileName = "custom.css", IsDefault = false }); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); - var customTheme = (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")); + var customTheme = (await UnitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")); Assert.NotNull(customTheme); await siteThemeService.UpdateDefault(customTheme.Id); - Assert.Equal(customTheme.Id, (await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Id); + Assert.Equal(customTheme.Id, (await UnitOfWork.SiteThemeRepository.GetDefaultTheme()).Id); } } diff --git a/API.Tests/Services/TachiyomiServiceTests.cs b/API.Tests/Services/TachiyomiServiceTests.cs index 9c50f572c..17e26139c 100644 --- a/API.Tests/Services/TachiyomiServiceTests.cs +++ b/API.Tests/Services/TachiyomiServiceTests.cs @@ -1,7 +1,5 @@ -using API.Extensions; -using API.Helpers.Builders; +using API.Helpers.Builders; using API.Services.Plus; -using API.Services.Tasks; namespace API.Tests.Services; using System.Collections.Generic; @@ -16,7 +14,6 @@ using API.Entities.Enums; using API.Helpers; using API.Services; using SignalR; -using Helpers; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -52,7 +49,7 @@ public class TachiyomiServiceTests Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For()); - _tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), _readerService); + _tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), _readerService); } diff --git a/API.Tests/Services/Test Data/BookService/Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf b/API.Tests/Services/Test Data/BookService/Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf new file mode 100644 index 000000000..9fe4811a7 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf differ diff --git a/API.Tests/Services/Test Data/BookService/Rollo at Work SP01.pdf b/API.Tests/Services/Test Data/BookService/Rollo at Work SP01.pdf new file mode 100644 index 000000000..0e0ffa8c7 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/Rollo at Work SP01.pdf differ diff --git a/API.Tests/Services/Test Data/BookService/encrypted.pdf b/API.Tests/Services/Test Data/BookService/encrypted.pdf new file mode 100644 index 000000000..64249b728 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/encrypted.pdf differ diff --git a/API.Tests/Services/Test Data/BookService/indirect.pdf b/API.Tests/Services/Test Data/BookService/indirect.pdf new file mode 100644 index 000000000..11ecdcb76 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/indirect.pdf differ diff --git a/API.Tests/Services/Test Data/CoverDbService/Existing/01.webp b/API.Tests/Services/Test Data/CoverDbService/Existing/01.webp new file mode 100644 index 000000000..0b46b66d2 Binary files /dev/null and b/API.Tests/Services/Test Data/CoverDbService/Existing/01.webp differ diff --git a/API.Tests/Services/Test Data/CoverDbService/Favicons/anilist.co.webp b/API.Tests/Services/Test Data/CoverDbService/Favicons/anilist.co.webp new file mode 100644 index 000000000..475824863 Binary files /dev/null and b/API.Tests/Services/Test Data/CoverDbService/Favicons/anilist.co.webp differ diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json new file mode 100644 index 000000000..791dcdc44 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Delete Series In UI - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Delete Series In UI - Manga.json new file mode 100644 index 000000000..791dcdc44 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Delete Series In UI - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json new file mode 100644 index 000000000..fe931174e --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json @@ -0,0 +1,5 @@ +[ + "Antarctic Press/Plush/Plush v01.cbz", + "Antarctic Press/Plush/Plush v02.cbz", + "Antarctic Press/Plush/Extra/Plush v03.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json new file mode 100644 index 000000000..06ed0f1f9 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json @@ -0,0 +1,6 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/My Dress-Up Darling v02.cbz", + "My Dress-Up Darling/My Dress-Up Darling ch 10.cbz", + "My Dress-Up Darling/Specials/My Dress-Up Darling - Omakes SP01.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json index d864da284..3fa9eebf7 100644 --- a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json @@ -1,5 +1,5 @@ [ - "Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", - "Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", - "Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz" -] \ No newline at end of file + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder (Non English) - Image.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder (Non English) - Image.json new file mode 100644 index 000000000..d283a3460 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder (Non English) - Image.json @@ -0,0 +1,5 @@ +[ + "葬送のフィリーレン/葬送のフィリーレン vol 1/0001.png", + "葬送のフィリーレン/葬送のフィリーレン vol 2/0002.png", + "葬送のフィリーレン/Specials/葬送のフリーレン 公式ファンブック SP01/0001.png" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json new file mode 100644 index 000000000..62106703c --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json @@ -0,0 +1,6 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling vol 1/0001.png", + "My Dress-Up Darling/My Dress-Up Darling vol 1/0002.png", + "My Dress-Up Darling/My Dress-Up Darling vol 2/0001.png", + "My Dress-Up Darling/Specials/My Dress-Up Darling SP01/0001.png" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json new file mode 100644 index 000000000..feb1fd99f --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json @@ -0,0 +1,3 @@ +[ + "Immoral Guild/Futoku no Guild v01.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json new file mode 100644 index 000000000..c9d4b14b6 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - Comic.json b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - Comic.json new file mode 100644 index 000000000..f5097b369 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - Comic.json @@ -0,0 +1,6 @@ +[ + "Dreadwolf/Dreadwolf Chapter 1-12.pdf", + "Dreadwolf/Dreadwolf Chapter 13-24.pdf", + "Dreadwolf/Dreadwolf Chapter 25.pdf", + "Dreadwolf/Dreadwolf Chapter 26.pdf" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - LightNovel.json b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - LightNovel.json new file mode 100644 index 000000000..f5097b369 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - LightNovel.json @@ -0,0 +1,6 @@ +[ + "Dreadwolf/Dreadwolf Chapter 1-12.pdf", + "Dreadwolf/Dreadwolf Chapter 13-24.pdf", + "Dreadwolf/Dreadwolf Chapter 25.pdf", + "Dreadwolf/Dreadwolf Chapter 26.pdf" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Scan Library Parses as ( - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Scan Library Parses as ( - Manga.json new file mode 100644 index 000000000..803f92586 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Scan Library Parses as ( - Manga.json @@ -0,0 +1,4 @@ +[ + "[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE/[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE.zip", + "[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE/[218565]-(C92) [BRIO (Puyocha)] Something Else (THE IDOLM@STE.zip" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series removed when no other changes are made - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series removed when no other changes are made - Manga.json new file mode 100644 index 000000000..c6ea3bc88 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series removed when no other changes are made - Manga.json @@ -0,0 +1,6 @@ +[ + "Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 1.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 2.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Extra - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Extra - Manga.json new file mode 100644 index 000000000..7ddcaecf4 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Extra - Manga.json @@ -0,0 +1,4 @@ +[ + "The Novel's Extra (Remake)/Vol.01.cbz", + "The Novel's Extra (Remake)/The Novel's Extra Chapter 100.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json new file mode 100644 index 000000000..6495c294f --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json @@ -0,0 +1,5 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru v02.cbz", + "My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru ch 10.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized 2 - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized 2 - Manga.json new file mode 100644 index 000000000..26619df88 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized 2 - Manga.json @@ -0,0 +1,5 @@ +[ + "Immoral Guild/Immoral Guild v01.cbz", + "Immoral Guild/Immoral Guild v02.cbz", + "Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json new file mode 100644 index 000000000..d6e91183b --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json @@ -0,0 +1,5 @@ +[ + "Immoral Guild/Immoral Guild v01.cbz", + "Immoral Guild/Immoral Guild v02.cbz", + "Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Prefix - Book.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Prefix - Book.json new file mode 100644 index 000000000..fc2bee18c --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Prefix - Book.json @@ -0,0 +1,3 @@ +[ + "The Avengers/The Avengers vol 1.pdf" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Sort Order - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Sort Order - Manga.json new file mode 100644 index 000000000..0b2dd765d --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Sort Order - Manga.json @@ -0,0 +1,5 @@ +[ + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 1-3.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 4.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 5.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolder always scanning fix publisher layout - Comic.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolder always scanning fix publisher layout - Comic.json new file mode 100644 index 000000000..4574ddb4e --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolder always scanning fix publisher layout - Comic.json @@ -0,0 +1,8 @@ +[ + "VizMedia/Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 1.cbz", + "VizMedia/Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 2.cbz", + "VizMedia/Seraph of the End/Seraph of the End Vol. 1.cbz", + "YenPress/Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "YenPress/Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "YenPress/The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 1.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders always scanning all series changes - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders always scanning all series changes - Manga.json new file mode 100644 index 000000000..3d7c74d5c --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders always scanning all series changes - Manga.json @@ -0,0 +1,11 @@ +[ + "Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 1/Frieren - Beyond Journey's End Ch. 0001.cbz", + "Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 1/Frieren - Beyond Journey's End Ch. 0002.cbz", + "Seraph of the End/Seraph of the End Vol. 1/Seraph of the End Ch. 0001.cbz", + "Spice and Wolf/Spice and Wolf Vol. 1/Spice and Wolf Vol. 1 Ch. 0001.cbz", + "Spice and Wolf/Spice and Wolf Vol. 1/Spice and Wolf Vol. 1 Ch. 0002.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2/Spice and Wolf Vol. 2 Ch. 0003.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 1/The Executioner and Her Way of Life Vol. 1 Ch. 0001.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 2/The Executioner and Her Way of Life Vol. 2 Ch. 0003.cbz" +] + diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root (2) - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root (2) - Manga.json new file mode 100644 index 000000000..103ea421a --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root (2) - Manga.json @@ -0,0 +1,6 @@ +[ + "Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0011.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0012.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root - Manga.json new file mode 100644 index 000000000..103ea421a --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root - Manga.json @@ -0,0 +1,6 @@ +[ + "Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0011.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0012.cbz" +] diff --git a/API.Tests/Services/VersionUpdaterServiceTests.cs b/API.Tests/Services/VersionUpdaterServiceTests.cs new file mode 100644 index 000000000..8be8f4aee --- /dev/null +++ b/API.Tests/Services/VersionUpdaterServiceTests.cs @@ -0,0 +1,446 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using API.DTOs.Update; +using API.Services; +using API.Services.Tasks; +using API.SignalR; +using Flurl.Http.Testing; +using Kavita.Common.EnvironmentInfo; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class VersionUpdaterServiceTests : IDisposable +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IEventHub _eventHub = Substitute.For(); + private readonly IDirectoryService _directoryService = Substitute.For(); + private readonly VersionUpdaterService _service; + private readonly string _tempPath; + private readonly HttpTest _httpTest; + + public VersionUpdaterServiceTests() + { + // Create temp directory for cache + _tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempPath); + _directoryService.LongTermCacheDirectory.Returns(_tempPath); + + _service = new VersionUpdaterService(_logger, _eventHub, _directoryService); + + // Setup HTTP testing + _httpTest = new HttpTest(); + + // Mock BuildInfo.Version for consistent testing + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.5.0.0")); + } + + public void Dispose() + { + _httpTest.Dispose(); + + // Cleanup temp directory + if (Directory.Exists(_tempPath)) + { + Directory.Delete(_tempPath, true); + } + + // Reset BuildInfo.Version + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, null); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task CheckForUpdate_ShouldReturnNull_WhenGithubApiReturnsNull() + { + + _httpTest.RespondWith("null"); + + + var result = await _service.CheckForUpdate(); + + + Assert.Null(result); + } + + // Depends on BuildInfo.CurrentVersion + //[Fact] + public async Task CheckForUpdate_ShouldReturnUpdateNotification_WhenNewVersionIsAvailable() + { + + var githubResponse = new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature 1\n- Feature 2\n# Fixed\n- Bug 1\n- Bug 2", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + + var result = await _service.CheckForUpdate(); + + + Assert.NotNull(result); + Assert.Equal("0.6.0", result.UpdateVersion); + Assert.Equal("0.5.0.0", result.CurrentVersion); + Assert.True(result.IsReleaseNewer); + Assert.Equal(2, result.Added.Count); + Assert.Equal(2, result.Fixed.Count); + } + + //[Fact] + public async Task CheckForUpdate_ShouldDetectEqualVersion() + { + // I can't figure this out + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.5.0.0")); + + + var githubResponse = new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature 1", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + + var result = await _service.CheckForUpdate(); + + + Assert.NotNull(result); + Assert.True(result.IsReleaseEqual); + Assert.False(result.IsReleaseNewer); + } + + + //[Fact] + public async Task PushUpdate_ShouldSendUpdateEvent_WhenNewerVersionAvailable() + { + + var update = new UpdateNotificationDto + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null, + PublishDate = null + }; + + + await _service.PushUpdate(update); + + + await _eventHub.Received(1).SendMessageAsync( + Arg.Is(MessageFactory.UpdateAvailable), + Arg.Any(), + Arg.Is(true) + ); + } + + [Fact] + public async Task PushUpdate_ShouldNotSendUpdateEvent_WhenVersionIsEqual() + { + + var update = new UpdateNotificationDto + { + UpdateVersion = "0.5.0.0", + CurrentVersion = "0.5.0.0", + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null, + PublishDate = null + }; + + + await _service.PushUpdate(update); + + + await _eventHub.DidNotReceive().SendMessageAsync( + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task GetAllReleases_ShouldReturnReleases_LimitedByCount() + { + + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature C", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.AddDays(-20).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + + var result = await _service.GetAllReleases(2); + + + Assert.Equal(2, result.Count); + Assert.Equal("0.7.0.0", result[0].UpdateVersion); + Assert.Equal("0.6.0", result[1].UpdateVersion); + } + + [Fact] + public async Task GetAllReleases_ShouldUseCachedData_WhenCacheIsValid() + { + + var releases = new List + { + new() + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-10) + .ToString("o"), + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null + } + }; + releases.Add(new() + { + UpdateVersion = "0.7.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-1) + .ToString("o"), + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null + }); + + // Create cache file + var cacheFilePath = Path.Combine(_tempPath, "github_releases_cache.json"); + await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases)); + File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow); // Ensure it's fresh + + + var result = await _service.GetAllReleases(); + + + Assert.Equal(2, result.Count); + Assert.Empty(_httpTest.CallLog); // No HTTP calls made + } + + [Fact] + public async Task GetAllReleases_ShouldFetchNewData_WhenCacheIsExpired() + { + + var releases = new List + { + new() + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-10) + .ToString("o"), + UpdateBody = null, + UpdateTitle = null, + UpdateUrl = null + } + }; + + // Create expired cache file + var cacheFilePath = Path.Combine(_tempPath, "github_releases_cache.json"); + await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases)); + File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow.AddHours(-2)); // Expired (older than 1 hour) + + // Setup HTTP response for new fetch + var newReleases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.ToString("o") + } + }; + + _httpTest.RespondWithJson(newReleases); + + + var result = await _service.GetAllReleases(); + + + Assert.Single(result); + Assert.Equal("0.7.0.0", result[0].UpdateVersion); + Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made + } + + public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount() + { + + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature C", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.AddDays(-20).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + + var result = await _service.GetNumberOfReleasesBehind(); + + + Assert.Equal(2 + 1, result); // Behind 0.7.0 and 0.6.0 - We have to add 1 because the current release is > 0.7.0 + } + + public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount_WithNightlies() + { + + var releases = new List + { + new + { + tag_name = "v0.7.1", + name = "Release 0.7.1", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.1", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + }; + + _httpTest.RespondWithJson(releases); + + + var result = await _service.GetNumberOfReleasesBehind(); + + + Assert.Equal(2, result); // We have to add 1 because the current release is > 0.7.0 + } + + [Fact] + public async Task ParseReleaseBody_ShouldExtractSections() + { + + var githubResponse = new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "This is a great release with many improvements!\n\n# Added\n- Feature 1\n- Feature 2\n# Fixed\n- Bug 1\n- Bug 2\n# Changed\n- Change 1\n# Developer\n- Dev note 1", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + + var result = await _service.CheckForUpdate(); + + + Assert.NotNull(result); + Assert.Equal(2, result.Added.Count); + Assert.Equal(2, result.Fixed.Count); + Assert.Equal(1, result.Changed.Count); + Assert.Equal(1, result.Developer.Count); + Assert.Contains("This is a great release", result.BlogPart); + } + + [Fact] + public async Task GetAllReleases_ShouldHandleNightlyBuilds() + { + + // Set BuildInfo.Version to a nightly build version + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.7.1.0")); + + // Mock regular releases + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + // Mock commit info for develop branch + _httpTest.RespondWithJson(new List()); + + + var result = await _service.GetAllReleases(); + + + Assert.NotNull(result); + Assert.True(result[0].IsOnNightlyInRelease); + } +} diff --git a/API.Tests/Services/WordCountAnalysisTests.cs b/API.Tests/Services/WordCountAnalysisTests.cs index ae17172b2..57c6ec7f6 100644 --- a/API.Tests/Services/WordCountAnalysisTests.cs +++ b/API.Tests/Services/WordCountAnalysisTests.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; @@ -11,10 +10,8 @@ using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; -using API.Services.Tasks; using API.Services.Tasks.Metadata; using API.SignalR; -using API.Tests.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -29,9 +26,10 @@ public class WordCountAnalysisTests : AbstractDbTest private const long MinHoursToRead = 1; private const float AvgHoursToRead = 1.66954792f; private const long MaxHoursToRead = 3; - public WordCountAnalysisTests() : base() + + public WordCountAnalysisTests() { - _readerService = new ReaderService(_unitOfWork, Substitute.For>(), + _readerService = new ReaderService(UnitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For()); @@ -39,9 +37,9 @@ public class WordCountAnalysisTests : AbstractDbTest protected override async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); + Context.Series.RemoveRange(Context.Series.ToList()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); } [Fact] @@ -59,7 +57,7 @@ public class WordCountAnalysisTests : AbstractDbTest MangaFormat.Epub).Build()) .Build(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) .WithSeries(series) .Build()); @@ -70,11 +68,11 @@ public class WordCountAnalysisTests : AbstractDbTest .Build(), }; - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var cacheService = new CacheHelper(new FileService()); - var service = new WordCountAnalyzerService(Substitute.For>(), _unitOfWork, + var service = new WordCountAnalyzerService(Substitute.For>(), UnitOfWork, Substitute.For(), cacheService, _readerService, Substitute.For()); @@ -86,7 +84,7 @@ public class WordCountAnalysisTests : AbstractDbTest Assert.Equal(MaxHoursToRead, series.MaxHoursToRead); // Validate the Chapter gets updated correctly - var volume = series.Volumes.First(); + var volume = series.Volumes[0]; Assert.Equal(WordCount, volume.WordCount); Assert.Equal(MinHoursToRead, volume.MinHoursToRead); Assert.Equal(AvgHoursToRead, volume.AvgHoursToRead); @@ -117,16 +115,16 @@ public class WordCountAnalysisTests : AbstractDbTest .Build()) .Build(); - _context.Library.Add(new LibraryBuilder("Test", LibraryType.Book) + Context.Library.Add(new LibraryBuilder("Test", LibraryType.Book) .WithSeries(series) .Build()); - await _context.SaveChangesAsync(); + await Context.SaveChangesAsync(); var cacheService = new CacheHelper(new FileService()); - var service = new WordCountAnalyzerService(Substitute.For>(), _unitOfWork, + var service = new WordCountAnalyzerService(Substitute.For>(), UnitOfWork, Substitute.For(), cacheService, _readerService, Substitute.For()); await service.ScanSeries(1, 1); @@ -142,21 +140,21 @@ public class WordCountAnalysisTests : AbstractDbTest .WithChapter(chapter2) .Build()); - series.Volumes.First().Chapters.Add(chapter2); - await _unitOfWork.CommitAsync(); + series.Volumes[0].Chapters.Add(chapter2); + await UnitOfWork.CommitAsync(); await service.ScanSeries(1, 1); Assert.Equal(WordCount * 2L, series.WordCount); Assert.Equal(MinHoursToRead * 2, series.MinHoursToRead); - var firstVolume = series.Volumes.ElementAt(0); + var firstVolume = series.Volumes[0]; Assert.Equal(WordCount, firstVolume.WordCount); Assert.Equal(MinHoursToRead, firstVolume.MinHoursToRead); Assert.True(series.AvgHoursToRead.Is(AvgHoursToRead * 2)); Assert.Equal(MaxHoursToRead, firstVolume.MaxHoursToRead); - var secondVolume = series.Volumes.ElementAt(1); + var secondVolume = series.Volumes[1]; Assert.Equal(WordCount, secondVolume.WordCount); Assert.Equal(MinHoursToRead, secondVolume.MinHoursToRead); Assert.Equal(AvgHoursToRead, secondVolume.AvgHoursToRead); diff --git a/API/API.csproj b/API/API.csproj index 266d87192..a7d1177dc 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -2,7 +2,7 @@ Default - net8.0 + net9.0 true Linux true @@ -12,10 +12,6 @@ latestmajor - - - - false @@ -54,58 +50,58 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - + - - - - - - - + + + + + + + - - - - - + + + + - - + + - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + + + + + @@ -115,16 +111,16 @@ - - - - + + + + @@ -142,6 +138,7 @@ + @@ -195,6 +192,10 @@ Always + + Always + + diff --git a/API/Assets/anilist-no-image-placeholder.jpg b/API/Assets/anilist-no-image-placeholder.jpg new file mode 100644 index 000000000..54c1066b6 Binary files /dev/null and b/API/Assets/anilist-no-image-placeholder.jpg differ diff --git a/API/Constants/CacheProfiles.cs b/API/Constants/CacheProfiles.cs index ee2cd204e..afc82f19c 100644 --- a/API/Constants/CacheProfiles.cs +++ b/API/Constants/CacheProfiles.cs @@ -8,10 +8,18 @@ public static class EasyCacheProfiles public const string RevokedJwt = "revokedJWT"; public const string Favicon = "favicon"; /// + /// Images for Publishers + /// + public const string Publisher = "publisherImages"; + /// /// If a user's license is valid /// public const string License = "license"; /// + /// License Information + /// + public const string LicenseInfo = "licenseInfo"; + /// /// Cache the libraries on the server /// public const string Library = "library"; @@ -19,4 +27,12 @@ public static class EasyCacheProfiles /// External Series metadata for Kavita+ recommendation /// public const string KavitaPlusExternalSeries = "kavita+externalSeries"; + /// + /// Match Series metadata for Kavita+ metadata download + /// + public const string KavitaPlusMatchSeries = "kavita+matchSeries"; + /// + /// All Locales on the Server + /// + public const string LocaleOptions = "locales"; } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 888be5eff..d8b9164af 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -15,6 +16,7 @@ using API.Errors; using API.Extensions; using API.Helpers.Builders; using API.Services; +using API.Services.Plus; using API.SignalR; using AutoMapper; using Hangfire; @@ -136,6 +138,12 @@ public class AccountController : BaseApiController return BadRequest(usernameValidation); } + // If Email is empty, default to the username + if (string.IsNullOrEmpty(registerDto.Email)) + { + registerDto.Email = registerDto.Username; + } + var user = new AppUserBuilder(registerDto.Username, registerDto.Email, await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build(); @@ -145,6 +153,9 @@ public class AccountController : BaseApiController // Assign default streams AddDefaultStreamsToUser(user); + // Assign default reading profile + await AddDefaultReadingProfileToUser(user); + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen")); if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Get("en", "validate-email", token)); @@ -350,10 +361,11 @@ public class AccountController : BaseApiController /// /// Returns just if the email was sent or server isn't reachable [HttpPost("update/email")] - public async Task UpdateEmail(UpdateEmailDto? dto) + public async Task> UpdateEmail(UpdateEmailDto? dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole)) + return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload")); @@ -362,12 +374,13 @@ public class AccountController : BaseApiController // Validate this user's password if (! await _userManager.CheckPasswordAsync(user, dto.Password)) { - _logger.LogCritical("A user tried to change {UserName}'s email, but password didn't validate", user.UserName); + _logger.LogWarning("A user tried to change {UserName}'s email, but password didn't validate", user.UserName); return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); } // Validate no other users exist with this email - if (user.Email!.Equals(dto.Email)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); + if (user.Email!.Equals(dto.Email)) + return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); // Check if email is used by another user var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); @@ -384,8 +397,10 @@ public class AccountController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "generate-token")); } + var isValidEmailAddress = _emailService.IsValidEmail(user.Email); var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - var shouldEmailUser = serverSettings.IsEmailSetup() || !_emailService.IsValidEmail(user.Email); + var shouldEmailUser = serverSettings.IsEmailSetup() || !isValidEmailAddress; + user.EmailConfirmed = !shouldEmailUser; user.ConfirmationToken = token; await _userManager.UpdateAsync(user); @@ -399,7 +414,8 @@ public class AccountController : BaseApiController return Ok(new InviteUserResponse { EmailLink = string.Empty, - EmailSent = false + EmailSent = false, + InvalidEmail = !isValidEmailAddress }); } @@ -407,7 +423,7 @@ public class AccountController : BaseApiController // Send a confirmation email try { - if (!_emailService.IsValidEmail(user.Email)) + if (!isValidEmailAddress) { _logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email); return Ok(new InviteUserResponse @@ -439,7 +455,8 @@ public class AccountController : BaseApiController return Ok(new InviteUserResponse { EmailLink = string.Empty, - EmailSent = true + EmailSent = true, + InvalidEmail = !isValidEmailAddress }); } catch (Exception ex) @@ -457,6 +474,7 @@ public class AccountController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); if (!await _accountService.CanChangeAgeRestriction(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); @@ -494,6 +512,7 @@ public class AccountController : BaseApiController var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (adminUser == null) return Unauthorized(); if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams); if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user")); @@ -509,6 +528,21 @@ public class AccountController : BaseApiController _unitOfWork.UserRepository.Update(user); } + // Check if email is changing for a non-admin user + var isUpdatingAnotherAccount = user.Id != adminUser.Id; + if (isUpdatingAnotherAccount && !string.IsNullOrEmpty(dto.Email) && user.Email != dto.Email) + { + // Validate username change + var errors = await _accountService.ValidateEmail(dto.Email); + if (errors.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "email-taken")); + + user.Email = dto.Email; + user.EmailConfirmed = true; // When an admin performs the flow, we assume the email address is able to receive data + + await _userManager.UpdateNormalizedEmailAsync(user); + _unitOfWork.UserRepository.Update(user); + } + // Update roles var existingRoles = await _userManager.GetRolesAsync(user); var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); @@ -578,7 +612,7 @@ public class AccountController : BaseApiController } /// - /// Requests the Invite Url for the UserId. Will return error if user is already validated. + /// Requests the Invite Url for the AppUserId. Will return error if user is already validated. /// /// /// Include the "https://ip:port/" in the generated link @@ -612,8 +646,7 @@ public class AccountController : BaseApiController if (adminUser == null) return Unauthorized(await _localizationService.Translate(userId, "permission-denied")); dto.Email = dto.Email.Trim(); - if (string.IsNullOrEmpty(dto.Email)) - return BadRequest(await _localizationService.Translate(userId, "invalid-payload")); + if (string.IsNullOrEmpty(dto.Email)) return BadRequest(await _localizationService.Translate(userId, "invalid-payload")); _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); @@ -623,7 +656,7 @@ public class AccountController : BaseApiController { var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); if (await _userManager.IsEmailConfirmedAsync(invitedUser!)) - return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-registered", invitedUser.UserName)); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-registered", invitedUser!.UserName)); return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-invited")); } @@ -639,6 +672,9 @@ public class AccountController : BaseApiController // Assign default streams AddDefaultStreamsToUser(user); + // Assign default reading profile + await AddDefaultReadingProfileToUser(user); + // Assign Roles var roles = dto.Roles; var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); @@ -749,6 +785,16 @@ public class AccountController : BaseApiController } } + private async Task AddDefaultReadingProfileToUser(AppUser user) + { + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Default Profile") + .WithKind(ReadingProfileKind.Default) + .Build(); + _unitOfWork.AppUserReadingProfileRepository.Add(profile); + await _unitOfWork.CommitAsync(); + } + /// /// Last step in authentication flow, confirms the email token for email /// @@ -897,7 +943,6 @@ public class AccountController : BaseApiController [EnableRateLimiting("Authentication")] public async Task> ForgotPassword([FromQuery] string email) { - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); if (user == null) @@ -998,6 +1043,8 @@ public class AccountController : BaseApiController await _localizationService.Translate(user.Id, "user-migration-needed")); if (user.EmailConfirmed) return BadRequest(await _localizationService.Translate(user.Id, "user-already-confirmed")); + // TODO: If the target user is read only, we might want to just forgo this + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); user.ConfirmationToken = token; _unitOfWork.UserRepository.Update(user); diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index 3b9f8cdda..4f8edd511 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -1,5 +1,9 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Constants; +using API.Data; using API.Data.ManualMigrations; +using API.DTOs; using API.DTOs.Progress; using API.Entities; using Microsoft.AspNetCore.Authorization; @@ -27,18 +31,7 @@ public class AdminController : BaseApiController [HttpGet("exists")] public async Task> AdminExists() { - var users = await _userManager.GetUsersInRoleAsync("Admin"); + var users = await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); return users.Count > 0; } - - /// - /// Set the progress information for a particular user - /// - /// - [Authorize("RequireAdminRole")] - [HttpPost("update-chapter-progress")] - public async Task> UpdateChapterProgress(UpdateUserProgressDto dto) - { - return Ok(await Task.FromResult(false)); - } } diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 962500ec7..e1d7da9e8 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -50,7 +50,7 @@ public class BookController : BaseApiController case MangaFormat.Epub: { var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0]; - using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions); bookTitle = book.Title; break; } @@ -102,7 +102,7 @@ public class BookController : BaseApiController var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); if (chapter == null) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist")); - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.LenientBookReaderOptions); var key = BookService.CoalesceKeyForAnyFile(book, file); if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Get("en", "file-missing")); diff --git a/API/Controllers/CBLController.cs b/API/Controllers/CBLController.cs index b4e674a1d..150628ced 100644 --- a/API/Controllers/CBLController.cs +++ b/API/Controllers/CBLController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using API.Constants; using API.DTOs.ReadingLists.CBL; using API.Extensions; using API.Services; @@ -20,11 +21,13 @@ public class CblController : BaseApiController { private readonly IReadingListService _readingListService; private readonly IDirectoryService _directoryService; + private readonly ILocalizationService _localizationService; - public CblController(IReadingListService readingListService, IDirectoryService directoryService) + public CblController(IReadingListService readingListService, IDirectoryService directoryService, ILocalizationService localizationService) { _readingListService = readingListService; _directoryService = directoryService; + _localizationService = localizationService; } /// @@ -44,6 +47,7 @@ public class CblController : BaseApiController var cblReadingList = await SaveAndLoadCblFile(cbl); var importSummary = await _readingListService.ValidateCblFile(userId, cblReadingList, useComicVineMatching); importSummary.FileName = cbl.FileName; + return Ok(importSummary); } catch (ArgumentNullException) @@ -90,6 +94,8 @@ public class CblController : BaseApiController [SwaggerIgnore] public async Task> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool useComicVineMatching = false) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { var userId = User.GetUserId(); diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs index b2c65282c..94535d499 100644 --- a/API/Controllers/ChapterController.cs +++ b/API/Controllers/ChapterController.cs @@ -1,18 +1,26 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Extensions; using API.Helpers; using API.Services; using API.Services.Tasks.Scanner.Parser; using API.SignalR; +using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Nager.ArticleNumber; namespace API.Controllers; @@ -22,12 +30,17 @@ public class ChapterController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly ILocalizationService _localizationService; private readonly IEventHub _eventHub; + private readonly ILogger _logger; + private readonly IMapper _mapper; - public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub) + public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger logger, + IMapper mapper) { _unitOfWork = unitOfWork; _localizationService = localizationService; _eventHub = eventHub; + _logger = logger; + _mapper = mapper; } /// @@ -54,22 +67,131 @@ public class ChapterController : BaseApiController [HttpDelete] public async Task> DeleteChapter(int chapterId) { - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId, + ChapterIncludes.Files | ChapterIncludes.ExternalReviews | ChapterIncludes.ExternalRatings); if (chapter == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); - var vol = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!; - _unitOfWork.ChapterRepository.Remove(chapter); + var vol = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId, VolumeIncludes.Chapters); + if (vol == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); - if (await _unitOfWork.CommitAsync()) + // If there is only 1 chapter within the volume, then we need to remove the volume + var needToRemoveVolume = vol.Chapters.Count == 1; + if (needToRemoveVolume) { - await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, MessageFactory.ChapterRemovedEvent(chapter.Id, vol.SeriesId), false); - return Ok(true); + _unitOfWork.VolumeRepository.Remove(vol); + } + else + { + _unitOfWork.ChapterRepository.Remove(chapter); } - return Ok(false); + // If we removed the volume, do an additional check if we need to delete the actual series as well or not + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(vol.SeriesId, SeriesIncludes.ExternalData | SeriesIncludes.Volumes); + var needToRemoveSeries = needToRemoveVolume && series != null && series.Volumes.Count <= 1; + if (needToRemoveSeries) + { + _unitOfWork.SeriesRepository.Remove(series!); + } + + + + if (!await _unitOfWork.CommitAsync()) return Ok(false); + + await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, MessageFactory.ChapterRemovedEvent(chapter.Id, vol.SeriesId), false); + if (needToRemoveVolume) + { + await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(chapter.VolumeId, vol.SeriesId), false); + } + + if (needToRemoveSeries) + { + await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, + MessageFactory.SeriesRemovedEvent(series!.Id, series.Name, series.LibraryId), false); + } + + return Ok(true); } + /// + /// Deletes multiple chapters and any volumes with no leftover chapters + /// + /// The ID of the series + /// The IDs of the chapters to be deleted + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("delete-multiple")] + public async Task> DeleteMultipleChapters([FromQuery] int seriesId, DeleteChaptersDto dto) + { + try + { + var chapterIds = dto.ChapterIds; + if (chapterIds == null || chapterIds.Count == 0) + { + return BadRequest("ChapterIds required"); + } + + // Fetch all chapters to be deleted + var chapters = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)).ToList(); + + // Group chapters by their volume + var volumesToUpdate = chapters.GroupBy(c => c.VolumeId).ToList(); + var removedVolumes = new List(); + + foreach (var volumeGroup in volumesToUpdate) + { + var volumeId = volumeGroup.Key; + var chaptersToDelete = volumeGroup.ToList(); + + // Fetch the volume + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters); + if (volume == null) + return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); + + // Check if all chapters in the volume are being deleted + var isVolumeToBeRemoved = volume.Chapters.Count == chaptersToDelete.Count; + + if (isVolumeToBeRemoved) + { + _unitOfWork.VolumeRepository.Remove(volume); + removedVolumes.Add(volume.Id); + } + else + { + // Remove only the specified chapters + _unitOfWork.ChapterRepository.Remove(chaptersToDelete); + } + } + + if (!await _unitOfWork.CommitAsync()) return Ok(false); + + // Send events for removed chapters + foreach (var chapter in chapters) + { + await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, + MessageFactory.ChapterRemovedEvent(chapter.Id, seriesId), false); + } + + // Send events for removed volumes + foreach (var volumeId in removedVolumes) + { + await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, + MessageFactory.VolumeRemovedEvent(volumeId, seriesId), false); + } + + return Ok(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occured while deleting chapters"); + return BadRequest(_localizationService.Translate(User.GetUserId(), "generic-error")); + } + + } + + /// /// Update chapter metadata /// @@ -87,6 +209,7 @@ public class ChapterController : BaseApiController if (chapter.AgeRating != dto.AgeRating) { chapter.AgeRating = dto.AgeRating; + chapter.KPlusOverrides.Remove(MetadataSettingField.AgeRating); } dto.Summary ??= string.Empty; @@ -94,6 +217,7 @@ public class ChapterController : BaseApiController if (chapter.Summary != dto.Summary.Trim()) { chapter.Summary = dto.Summary.Trim(); + chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterSummary); } if (chapter.Language != dto.Language) @@ -109,11 +233,13 @@ public class ChapterController : BaseApiController if (chapter.TitleName != dto.TitleName) { chapter.TitleName = dto.TitleName; + chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterTitle); } if (chapter.ReleaseDate != dto.ReleaseDate) { chapter.ReleaseDate = dto.ReleaseDate; + chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterReleaseDate); } if (!string.IsNullOrEmpty(dto.ISBN) && ArticleNumberHelper.IsValidIsbn10(dto.ISBN) || @@ -136,131 +262,123 @@ public class ChapterController : BaseApiController #region Genres - if (dto.Genres is {Count: > 0}) - { - chapter.Genres ??= new List(); - await GenreHelper.UpdateChapterGenres(chapter, dto.Genres.Select(t => t.Title), _unitOfWork); - } + chapter.Genres ??= []; + await GenreHelper.UpdateChapterGenres(chapter, dto.Genres.Select(t => t.Title), _unitOfWork); #endregion #region Tags - if (dto.Tags is {Count: > 0}) - { - chapter.Tags ??= new List(); - await TagHelper.UpdateChapterTags(chapter, dto.Tags.Select(t => t.Title), _unitOfWork); - } + chapter.Tags ??= []; + await TagHelper.UpdateChapterTags(chapter, dto.Tags.Select(t => t.Title), _unitOfWork); #endregion #region People - if (PersonHelper.HasAnyPeople(dto)) - { - chapter.People ??= new List(); + chapter.People ??= []; + // Update writers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Writers.Select(p => p.Name).ToList(), + PersonRole.Writer, + _unitOfWork + ); - // Update writers - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Writers.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Writer, - _unitOfWork - ); + // Update characters + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Characters.Select(p => p.Name).ToList(), + PersonRole.Character, + _unitOfWork + ); - // Update characters - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Characters.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Character, - _unitOfWork - ); + // Update pencillers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Pencillers.Select(p => p.Name).ToList(), + PersonRole.Penciller, + _unitOfWork + ); - // Update pencillers - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Pencillers.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Penciller, - _unitOfWork - ); + // Update inkers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Inkers.Select(p => p.Name).ToList(), + PersonRole.Inker, + _unitOfWork + ); - // Update inkers - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Inkers.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Inker, - _unitOfWork - ); + // Update colorists + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Colorists.Select(p => p.Name).ToList(), + PersonRole.Colorist, + _unitOfWork + ); - // Update colorists - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Colorists.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Colorist, - _unitOfWork - ); + // Update letterers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Letterers.Select(p => p.Name).ToList(), + PersonRole.Letterer, + _unitOfWork + ); - // Update letterers - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Letterers.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Letterer, - _unitOfWork - ); + // Update cover artists + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.CoverArtists.Select(p => p.Name).ToList(), + PersonRole.CoverArtist, + _unitOfWork + ); - // Update cover artists - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.CoverArtists.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.CoverArtist, - _unitOfWork - ); + // Update editors + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Editors.Select(p => p.Name).ToList(), + PersonRole.Editor, + _unitOfWork + ); - // Update editors - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Editors.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Editor, - _unitOfWork - ); + // TODO: Only remove field if changes were made + chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterPublisher); + // Update publishers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Publishers.Select(p => p.Name).ToList(), + PersonRole.Publisher, + _unitOfWork + ); - // Update publishers - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Publishers.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Publisher, - _unitOfWork - ); + // Update translators + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Translators.Select(p => p.Name).ToList(), + PersonRole.Translator, + _unitOfWork + ); - // Update translators - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Translators.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Translator, - _unitOfWork - ); + // Update imprints + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Imprints.Select(p => p.Name).ToList(), + PersonRole.Imprint, + _unitOfWork + ); - // Update imprints - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Imprints.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Imprint, - _unitOfWork - ); + // Update teams + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Teams.Select(p => p.Name).ToList(), + PersonRole.Team, + _unitOfWork + ); - // Update teams - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Teams.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Team, - _unitOfWork - ); - - // Update locations - await PersonHelper.UpdateChapterPeopleAsync( - chapter, - dto.Locations.Select(p => Parser.Normalize(p.Name)).ToList(), - PersonRole.Location, - _unitOfWork - ); - } + // Update locations + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Locations.Select(p => p.Name).ToList(), + PersonRole.Location, + _unitOfWork + ); #endregion #region Locks @@ -302,6 +420,39 @@ public class ChapterController : BaseApiController return Ok(); } + /// + /// Returns Ratings and Reviews for an individual Chapter + /// + /// + /// + [HttpGet("chapter-detail-plus")] + public async Task> ChapterDetailPlus([FromQuery] int chapterId) + { + var ret = new ChapterDetailPlusDto(); + var userReviews = (await _unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId, User.GetUserId())) + .Where(r => !string.IsNullOrEmpty(r.Body)) + .OrderByDescending(review => review.Username.Equals(User.GetUsername()) ? 1 : 0) + .ToList(); + + var ownRating = await _unitOfWork.UserRepository.GetUserChapterRatingAsync(User.GetUserId(), chapterId); + if (ownRating != null) + { + ret.Rating = ownRating.Rating; + ret.HasBeenRated = ownRating.HasBeenRated; + } + + var externalReviews = await _unitOfWork.ChapterRepository.GetExternalChapterReviewDtos(chapterId); + if (externalReviews.Count > 0) + { + userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(externalReviews)); + } + + ret.Reviews = userReviews; + + ret.Ratings = await _unitOfWork.ChapterRepository.GetExternalChapterRatingDtos(chapterId); + + return Ok(ret); + } } diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index b49d6aa40..2c0abc609 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -105,6 +105,8 @@ public class CollectionController : BaseApiController [HttpPost("update")] public async Task UpdateTag(AppUserCollectionDto updatedTag) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { if (await _collectionService.UpdateTag(updatedTag, User.GetUserId())) @@ -130,6 +132,8 @@ public class CollectionController : BaseApiController [HttpPost("promote-multiple")] public async Task PromoteMultipleCollections(PromoteCollectionsDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // This needs to take into account owner as I can select other users cards var collections = await _unitOfWork.CollectionTagRepository.GetCollectionsByIds(dto.CollectionIds); var userId = User.GetUserId(); @@ -161,6 +165,8 @@ public class CollectionController : BaseApiController [HttpPost("delete-multiple")] public async Task DeleteMultipleCollections(DeleteCollectionsDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // This needs to take into account owner as I can select other users cards var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); if (user == null) return Unauthorized(); @@ -182,6 +188,8 @@ public class CollectionController : BaseApiController [HttpPost("update-for-series")] public async Task AddToMultipleSeries(CollectionTagBulkAddDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // Create a new tag and save var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); if (user == null) return Unauthorized(); @@ -223,6 +231,8 @@ public class CollectionController : BaseApiController [HttpPost("update-series")] public async Task RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(updateSeriesForTagDto.Tag.Id, CollectionIncludes.Series); @@ -247,6 +257,8 @@ public class CollectionController : BaseApiController [HttpDelete] public async Task DeleteTag(int tagId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); @@ -276,6 +288,8 @@ public class CollectionController : BaseApiController [HttpGet("mal-stacks")] public async Task>> GetMalStacksForUser() { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId())); } @@ -289,6 +303,8 @@ public class CollectionController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // Validation check to ensure stack doesn't exist already if (await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, user.Id)) diff --git a/API/Controllers/DeviceController.cs b/API/Controllers/DeviceController.cs index cfd3c3416..8c8081d98 100644 --- a/API/Controllers/DeviceController.cs +++ b/API/Controllers/DeviceController.cs @@ -110,18 +110,18 @@ public class DeviceController : BaseApiController [HttpPost("send-to")] public async Task SendToDevice(SendToDeviceDto dto) { - if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "ChapterIds")); - if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId")); + var userId = User.GetUserId(); + if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(userId, "greater-0", "ChapterIds")); + if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "DeviceId")); var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice(); if (!isEmailSetup) - return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email")); + return BadRequest(await _localizationService.Translate(userId, "send-to-kavita-email")); // // Validate that the device belongs to the user - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Devices); - if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-unallowed")); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Devices); + if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(userId, "send-to-unallowed")); - var userId = User.GetUserId(); await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"), "started"), userId); @@ -145,26 +145,30 @@ public class DeviceController : BaseApiController } - + /// + /// Attempts to send a whole series to a device. + /// + /// + /// [HttpPost("send-series-to")] public async Task SendSeriesToDevice(SendSeriesToDeviceDto dto) { - if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "SeriesId")); - if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId")); + var userId = User.GetUserId(); + if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "SeriesId")); + if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "DeviceId")); var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice(); if (!isEmailSetup) - return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email")); + return BadRequest(await _localizationService.Translate(userId, "send-to-kavita-email")); - var userId = User.GetUserId(); await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, - MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), + MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"), "started"), userId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Volumes | SeriesIncludes.Chapters); - if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); + if (series == null) return BadRequest(await _localizationService.Translate(userId, "series-doesnt-exist")); var chapterIds = series.Volumes.SelectMany(v => v.Chapters.Select(c => c.Id)).ToList(); try { @@ -173,16 +177,16 @@ public class DeviceController : BaseApiController } catch (KavitaException ex) { - return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); + return BadRequest(await _localizationService.Translate(userId, ex.Message)); } finally { await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, - MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), + MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"), "ended"), userId); } - return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-send-to")); + return BadRequest(await _localizationService.Translate(userId, "generic-send-to")); } } diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index ba65aec70..5a249c9a8 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Downloads; using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Services; using API.SignalR; @@ -157,7 +158,8 @@ public class DownloadController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, filename, $"Downloading {filename}", 0F, "started")); - if (files.Count == 1) + + if (files.Count == 1 && files.First().Format != MangaFormat.Image) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, @@ -166,15 +168,17 @@ public class DownloadController : BaseApiController } var filePath = _archiveService.CreateZipFromFoldersForDownload(files.Select(c => c.FilePath).ToList(), tempFolder, ProgressCallback); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, filename, "Download Complete", 1F, "ended")); + return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(downloadName), true); async Task ProgressCallback(Tuple progressInfo) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(username, filename, $"Extracting {Path.GetFileNameWithoutExtension(progressInfo.Item1)}", + MessageFactory.DownloadProgressEvent(username, filename, $"Processing {Path.GetFileNameWithoutExtension(progressInfo.Item1)}", Math.Clamp(progressInfo.Item2, 0F, 1F))); } } @@ -192,8 +196,10 @@ public class DownloadController : BaseApiController public async Task DownloadSeries(int seriesId) { if (!await HasDownloadPermission()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); if (series == null) return BadRequest("Invalid Series"); + var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); try { diff --git a/API/Controllers/EmailController.cs b/API/Controllers/EmailController.cs new file mode 100644 index 000000000..c1e3ad413 --- /dev/null +++ b/API/Controllers/EmailController.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Email; +using API.Helpers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +[Authorize(Policy = "RequireAdminRole")] +public class EmailController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + + public EmailController(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + [HttpGet("all")] + public async Task>> GetEmails() + { + return Ok(await _unitOfWork.EmailHistoryRepository.GetEmailDtos(UserParams.Default)); + } +} diff --git a/API/Controllers/FilterController.cs b/API/Controllers/FilterController.cs index e8cb71117..7fcffb7da 100644 --- a/API/Controllers/FilterController.cs +++ b/API/Controllers/FilterController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs.Dashboard; @@ -9,7 +10,9 @@ using API.DTOs.Filtering.v2; using API.Entities; using API.Extensions; using API.Helpers; +using API.Services; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -21,10 +24,17 @@ namespace API.Controllers; public class FilterController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IStreamService _streamService; + private readonly ILogger _logger; - public FilterController(IUnitOfWork unitOfWork) + public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IStreamService streamService, + ILogger logger) { _unitOfWork = unitOfWork; + _localizationService = localizationService; + _streamService = streamService; + _logger = logger; } /// @@ -37,6 +47,7 @@ public class FilterController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.SmartFilters); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (string.IsNullOrWhiteSpace(dto.Name)) return BadRequest("Name must be set"); if (Seed.DefaultStreams.Any(s => s.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase))) @@ -78,6 +89,8 @@ public class FilterController : BaseApiController [HttpDelete] public async Task DeleteFilter(int filterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId); if (filter == null) return Ok(); // This needs to delete any dashboard filters that have it too @@ -113,4 +126,57 @@ public class FilterController : BaseApiController { return Ok(SmartFilterHelper.Decode(dto.EncodedFilter)); } + + /// + /// Rename a Smart Filter given the filterId and new name + /// + /// + /// + /// + [HttpPost("rename")] + public async Task RenameFilter([FromQuery] int filterId, [FromQuery] string name) + { + try + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), + AppUserIncludes.SmartFilters); + if (user == null) return Unauthorized(); + + name = name.Trim(); + + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) + { + return BadRequest(await _localizationService.Translate(user.Id, "permission-denied")); + } + + if (string.IsNullOrWhiteSpace(name)) + { + return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-name-required")); + } + + if (Seed.DefaultStreams.Any(s => s.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))) + { + return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-system-name")); + } + + var filter = user.SmartFilters.FirstOrDefault(f => f.Id == filterId); + if (filter == null) + { + return BadRequest(await _localizationService.Translate(user.Id, "filter-not-found")); + } + + filter.Name = name; + _unitOfWork.AppUserSmartFilterRepository.Update(filter); + await _unitOfWork.CommitAsync(); + + await _streamService.RenameSmartFilterStreams(filter); + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when renaming smart filter: {FilterId}", filterId); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); + } + + } } diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 011ae471f..87e0542d1 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -7,6 +7,7 @@ using API.Data; using API.Entities.Enums; using API.Extensions; using API.Services; +using API.Services.Tasks.Metadata; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MimeTypes; @@ -26,17 +27,19 @@ public class ImageController : BaseApiController private readonly IImageService _imageService; private readonly ILocalizationService _localizationService; private readonly IReadingListService _readingListService; + private readonly ICoverDbService _coverDbService; /// public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService, IImageService imageService, ILocalizationService localizationService, - IReadingListService readingListService) + IReadingListService readingListService, ICoverDbService coverDbService) { _unitOfWork = unitOfWork; _directoryService = directoryService; _imageService = imageService; _localizationService = localizationService; _readingListService = readingListService; + _coverDbService = coverDbService; } /// @@ -230,7 +233,7 @@ public class ImageController : BaseApiController try { domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, - await _imageService.DownloadFaviconAsync(url, encodeFormat)); + await _coverDbService.DownloadFaviconAsync(url, encodeFormat)); } catch (Exception) { @@ -270,7 +273,7 @@ public class ImageController : BaseApiController try { domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory, - await _imageService.DownloadPublisherImageAsync(publisherName, encodeFormat)); + await _coverDbService.DownloadPublisherImageAsync(publisherName, encodeFormat)); } catch (Exception) { diff --git a/API/Controllers/KoreaderController.cs b/API/Controllers/KoreaderController.cs new file mode 100644 index 000000000..8c4c41585 --- /dev/null +++ b/API/Controllers/KoreaderController.cs @@ -0,0 +1,119 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Koreader; +using API.Entities; +using API.Extensions; +using API.Services; +using Kavita.Common; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using static System.Net.WebRequestMethods; + +namespace API.Controllers; +#nullable enable + +/// +/// The endpoint to interface with Koreader's Progress Sync plugin. +/// +/// +/// Koreader uses a different form of authentication. It stores the username and password in headers. +/// https://github.com/koreader/koreader/blob/master/plugins/kosync.koplugin/KOSyncClient.lua +/// +[AllowAnonymous] +public class KoreaderController : BaseApiController +{ + + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IKoreaderService _koreaderService; + private readonly ILogger _logger; + + public KoreaderController(IUnitOfWork unitOfWork, ILocalizationService localizationService, + IKoreaderService koreaderService, ILogger logger) + { + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _koreaderService = koreaderService; + _logger = logger; + } + + // We won't allow users to be created from Koreader. Rather, they + // must already have an account. + /* + [HttpPost("/users/create")] + public IActionResult CreateUser(CreateUserRequest request) + { + } + */ + + [HttpGet("{apiKey}/users/auth")] + public async Task Authenticate(string apiKey) + { + var userId = await GetUserId(apiKey); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null) return Unauthorized(); + + return Ok(new { username = user.UserName }); + } + + /// + /// Syncs book progress with Kavita. Will attempt to save the underlying reader position if possible. + /// + /// + /// + /// + [HttpPut("{apiKey}/syncs/progress")] + public async Task> UpdateProgress(string apiKey, KoreaderBookDto request) + { + try + { + var userId = await GetUserId(apiKey); + await _koreaderService.SaveProgress(request, userId); + + return Ok(new KoreaderProgressUpdateDto{ Document = request.Document, Timestamp = DateTime.UtcNow }); + } + catch (KavitaException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Gets book progress from Kavita, if not found will return a 400 + /// + /// + /// + /// + [HttpGet("{apiKey}/syncs/progress/{ebookHash}")] + public async Task> GetProgress(string apiKey, string ebookHash) + { + try + { + var userId = await GetUserId(apiKey); + var response = await _koreaderService.GetProgress(ebookHash, userId); + _logger.LogDebug("Koreader response progress for User ({UserId}): {Progress}", userId, response.Progress.Sanitize()); + + return Ok(response); + } + catch (KavitaException ex) + { + return BadRequest(ex.Message); + } + } + + private async Task GetUserId(string apiKey) + { + try + { + return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + } + catch + { + throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist")); + } + } +} diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 8c1eeb0c0..8f9b18317 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -20,6 +20,7 @@ using API.SignalR; using AutoMapper; using EasyCaching.Core; using Hangfire; +using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -80,7 +81,8 @@ public class LibraryController : BaseApiController .WithIncludeInDashboard(dto.IncludeInDashboard) .WithManageCollections(dto.ManageCollections) .WithManageReadingLists(dto.ManageReadingLists) - .WIthAllowScrobbling(dto.AllowScrobbling) + .WithAllowScrobbling(dto.AllowScrobbling) + .WithAllowMetadataMatching(dto.AllowMetadataMatching) .Build(); library.LibraryFileTypes = dto.FileGroupTypes @@ -191,7 +193,6 @@ public class LibraryController : BaseApiController var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username).ToList(); await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); - _logger.LogDebug("Caching libraries for {Key}", cacheKey); return Ok(ret.Find(l => l.Id == libraryId)); } @@ -212,7 +213,6 @@ public class LibraryController : BaseApiController var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username); await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); - _logger.LogDebug("Caching libraries for {Key}", cacheKey); return Ok(ret); } @@ -350,27 +350,6 @@ public class LibraryController : BaseApiController return Ok(); } - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("analyze")] - public ActionResult Analyze(int libraryId) - { - _taskScheduler.AnalyzeFilesForLibrary(libraryId, true); - return Ok(); - } - - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("analyze-multiple")] - public ActionResult AnalyzeMultiple(BulkActionDto dto) - { - foreach (var libraryId in dto.Ids) - { - _taskScheduler.AnalyzeFilesForLibrary(libraryId, dto.Force ?? false); - } - - return Ok(); - } - - /// /// Copy the library settings (adv tab + optional type) to a set of other libraries. /// @@ -439,20 +418,65 @@ public class LibraryController : BaseApiController .Distinct() .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); - var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, - new List() {dto.FolderPath}); + var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, [dto.FolderPath]); _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); return Ok(); } + /// + /// Deletes the library and all series within it. + /// + /// This does not touch any files + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpDelete("delete")] public async Task> DeleteLibrary(int libraryId) + { + _logger.LogInformation("Library {LibraryId} is being deleted by {UserName}", libraryId, User.GetUsername()); + + try + { + return Ok(await DeleteLibrary(libraryId, User.GetUserId())); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes multiple libraries and all series within it. + /// + /// This does not touch any files + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete("delete-multiple")] + public async Task> DeleteMultipleLibraries([FromQuery] List libraryIds) { var username = User.GetUsername(); - _logger.LogInformation("Library {LibraryId} is being deleted by {UserName}", libraryId, username); + _logger.LogInformation("Libraries {LibraryIds} are being deleted by {UserName}", libraryIds, username); + + foreach (var libraryId in libraryIds) + { + try + { + await DeleteLibrary(libraryId, User.GetUserId()); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + return Ok(); + } + + private async Task DeleteLibrary(int libraryId, int userId) + { var series = await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId); var seriesIds = series.Select(x => x.Id).ToArray(); var chapterIds = @@ -463,16 +487,19 @@ public class LibraryController : BaseApiController if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId)) { _logger.LogInformation("User is attempting to delete a library while a scan is in progress"); - return BadRequest(await _localizationService.Translate(User.GetUserId(), "delete-library-while-scan")); + throw new KavitaException(await _localizationService.Translate(userId, "delete-library-while-scan")); } var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); - if (library == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-doesnt-exist")); + if (library == null) + { + throw new KavitaException(await _localizationService.Translate(userId, "library-doesnt-exist")); + } + // Due to a bad schema that I can't figure out how to fix, we need to erase all RelatedSeries before we delete the library // Aka SeriesRelation has an invalid foreign key - foreach (var s in await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id, - SeriesIncludes.Related)) + foreach (var s in await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id, SeriesIncludes.Related)) { s.Relations = new List(); _unitOfWork.SeriesRepository.Update(s); @@ -489,7 +516,7 @@ public class LibraryController : BaseApiController await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, - MessageFactory.SideNavUpdateEvent(User.GetUserId()), false); + MessageFactory.SideNavUpdateEvent(userId), false); if (chapterIds.Any()) { @@ -508,13 +535,13 @@ public class LibraryController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false); - return Ok(true); + return true; } catch (Exception ex) { _logger.LogError(ex, "There was a critical issue. Please try again"); await _unitOfWork.RollbackAsync(); - return Ok(false); + return false; } } @@ -595,6 +622,10 @@ public class LibraryController : BaseApiController library.ManageCollections = dto.ManageCollections; library.ManageReadingLists = dto.ManageReadingLists; library.AllowScrobbling = dto.AllowScrobbling; + library.AllowMetadataMatching = dto.AllowMetadataMatching; + library.EnableMetadata = dto.EnableMetadata; + library.RemovePrefixForSortName = dto.RemovePrefixForSortName; + library.LibraryFileTypes = dto.FileGroupTypes .Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id}) .Distinct() @@ -612,6 +643,7 @@ public class LibraryController : BaseApiController library.AllowScrobbling = false; } + _unitOfWork.LibraryRepository.Update(library); } diff --git a/API/Controllers/LicenseController.cs b/API/Controllers/LicenseController.cs index d831f10b2..30ed68771 100644 --- a/API/Controllers/LicenseController.cs +++ b/API/Controllers/LicenseController.cs @@ -2,14 +2,17 @@ using System.Threading.Tasks; using API.Constants; using API.Data; -using API.DTOs.License; +using API.DTOs.KavitaPlus.License; using API.Entities.Enums; using API.Extensions; using API.Services; using API.Services.Plus; +using EasyCaching.Core; +using Hangfire; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using TaskScheduler = API.Services.TaskScheduler; namespace API.Controllers; @@ -20,7 +23,8 @@ public class LicenseController( ILogger logger, ILicenseService licenseService, ILocalizationService localizationService, - ITaskScheduler taskScheduler) + ITaskScheduler taskScheduler, + IEasyCachingProviderFactory cachingProviderFactory) : BaseApiController { /// @@ -31,8 +35,13 @@ public class LicenseController( [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] public async Task> HasValidLicense(bool forceCheck = false) { + var result = await licenseService.HasActiveLicense(forceCheck); - if (result) + + var licenseInfoProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + var cacheValue = await licenseInfoProvider.GetAsync(LicenseService.CacheKey); + + if (result && !cacheValue.IsNull && !cacheValue.Value) { await taskScheduler.ScheduleKavitaPlusTasks(); } @@ -41,7 +50,7 @@ public class LicenseController( } /// - /// Has any license + /// Has any license registered with the instance. Does not check Kavita+ API /// /// [Authorize("RequireAdminRole")] @@ -53,6 +62,30 @@ public class LicenseController( (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value)); } + /// + /// Asks Kavita+ for the latest license info + /// + /// Force checking the API and skip the 8 hour cache + /// + [Authorize("RequireAdminRole")] + [HttpGet("info")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] + public async Task> GetLicenseInfo(bool forceCheck = false) + { + try + { + return Ok(await licenseService.GetLicenseInfo(forceCheck)); + } + catch (Exception) + { + return Ok(null); + } + } + + /// + /// Remove the Kavita+ License on the Server + /// + /// [Authorize("RequireAdminRole")] [HttpDelete] [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] @@ -63,10 +96,13 @@ public class LicenseController( setting.Value = null; unitOfWork.SettingsRepository.Update(setting); await unitOfWork.CommitAsync(); - await taskScheduler.ScheduleKavitaPlusTasks(); + + TaskScheduler.RemoveKavitaPlusTasks(); + return Ok(); } + [Authorize("RequireAdminRole")] [HttpPost("reset")] public async Task ResetLicense(UpdateLicenseDto dto) diff --git a/API/Controllers/LocaleController.cs b/API/Controllers/LocaleController.cs index 9190c72cb..6e3a2ec78 100644 --- a/API/Controllers/LocaleController.cs +++ b/API/Controllers/LocaleController.cs @@ -2,9 +2,16 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.DTOs; using API.DTOs.Filtering; using API.Services; +using EasyCaching.Core; +using Kavita.Common.EnvironmentInfo; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; namespace API.Controllers; @@ -13,43 +20,34 @@ namespace API.Controllers; public class LocaleController : BaseApiController { private readonly ILocalizationService _localizationService; + private readonly IEasyCachingProvider _localeCacheProvider; - public LocaleController(ILocalizationService localizationService) + private static readonly string CacheKey = "locales_" + BuildInfo.Version; + + public LocaleController(ILocalizationService localizationService, IEasyCachingProviderFactory cachingProviderFactory) { _localizationService = localizationService; + _localeCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.LocaleOptions); } + /// + /// Returns all applicable locales on the server + /// + /// This can be cached as it will not change per version. + /// + [AllowAnonymous] [HttpGet] - public ActionResult> GetAllLocales() + public async Task>> GetAllLocales() { - // Check if temp/locale_map.json exists + var result = await _localeCacheProvider.GetAsync>(CacheKey); + if (result.HasValue) + { + return Ok(result.Value); + } - // If not, scan the 2 locale files and calculate empty keys or empty values + var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f); + await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(1)); - // Formulate the Locale object with Percentage - var languages = _localizationService.GetLocales().Select(c => - { - try - { - var cult = new CultureInfo(c); - return new LanguageDto() - { - Title = cult.DisplayName, - IsoCode = cult.IetfLanguageTag - }; - } - catch (Exception ex) - { - // Some OS' don't have all culture codes supported like PT_BR, thus we need to default - return new LanguageDto() - { - Title = c, - IsoCode = c - }; - } - }) - .Where(l => !string.IsNullOrEmpty(l.IsoCode)) - .OrderBy(d => d.Title); - return Ok(languages); + return Ok(ret); } } diff --git a/API/Controllers/ManageController.cs b/API/Controllers/ManageController.cs new file mode 100644 index 000000000..3641ddd74 --- /dev/null +++ b/API/Controllers/ManageController.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.DTOs.KavitaPlus.Manage; +using API.Services.Plus; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +/// +/// All things centered around Managing the Kavita instance, that isn't aligned with an entity +/// +[Authorize("RequireAdminRole")] +public class ManageController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILicenseService _licenseService; + + public ManageController(IUnitOfWork unitOfWork, ILicenseService licenseService) + { + _unitOfWork = unitOfWork; + _licenseService = licenseService; + } + + /// + /// Returns a list of all Series that is Kavita+ applicable to metadata match and the status of it + /// + /// + [Authorize("RequireAdminRole")] + [HttpPost("series-metadata")] + public async Task>> SeriesMetadata(ManageMatchFilterDto filter) + { + if (!await _licenseService.HasActiveLicense()) return Ok(Array.Empty()); + + return Ok(await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeries(filter)); + } +} diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index ccae1a835..cab33692a 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -5,13 +5,17 @@ using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; +using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities.Enums; using API.Extensions; +using API.Helpers; using API.Services; using API.Services.Plus; using Kavita.Common.Extensions; @@ -31,20 +35,33 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// Fetches genres from the instance /// /// String separated libraryIds or null for all genres + /// Context from which this API was invoked /// [HttpGet("genres")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})] - public async Task>> GetAllGenres(string? libraryIds) + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds", "context"])] + public async Task>> GetAllGenres(string? libraryIds, QueryContext context = QueryContext.None) { - var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); + var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToList(); - // NOTE: libraryIds isn't hooked up in the frontend - if (ids is {Count: > 0}) - { - return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids)); - } + return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context)); + } - return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId())); + /// + /// Returns a list of Genres with counts for counts when Genre is on Series/Chapter + /// + /// + [HttpPost("genres-with-counts")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)] + public async Task>> GetBrowseGenres(UserParams? userParams = null) + { + userParams ??= UserParams.Default; + + var list = await unitOfWork.GenreRepository.GetBrowseableGenre(User.GetUserId(), userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + + return Ok(list); } /// @@ -75,6 +92,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc { return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids)); } + return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId())); } @@ -95,6 +113,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId())); } + /// + /// Returns a list of Tags with counts for counts when Tag is on Series/Chapter + /// + /// + [HttpPost("tags-with-counts")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)] + public async Task>> GetBrowseTags(UserParams? userParams = null) + { + userParams ??= UserParams.Default; + + var list = await unitOfWork.TagRepository.GetBrowseableTag(User.GetUserId(), userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + + return Ok(list); + } + /// /// Fetches all age ratings from the instance /// @@ -124,7 +158,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// String separated libraryIds or null for all publication status /// This API is cached for 1 hour, varying by libraryIds /// - [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = ["libraryIds"])] [HttpGet("publication-status")] public ActionResult> GetAllPublicationStatus(string? libraryIds) { @@ -148,7 +182,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// String separated libraryIds or null for all ratings /// [HttpGet("languages")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new []{"libraryIds"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = ["libraryIds"])] public async Task>> GetAllLanguages(string? libraryIds) { var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); @@ -171,20 +205,21 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc }).Where(l => !string.IsNullOrEmpty(l.IsoCode)); } - /// - /// Returns summary for the chapter + /// Given a language code returns the display name /// - /// + /// /// - [HttpGet("chapter-summary")] - public async Task> GetChapterSummary(int chapterId) + [HttpGet("language-title")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["code"])] + public ActionResult GetLanguageTitle(string code) { - // TODO: This doesn't seem used anywhere - if (chapterId <= 0) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); - var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - if (chapter == null) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); - return Ok(chapter.Summary); + if (string.IsNullOrEmpty(code)) return BadRequest("Code must be provided"); + + return CultureInfo.GetCultures(CultureTypes.AllCultures) + .Where(l => code.Equals(l.IetfLanguageTag)) + .Select(c => c.DisplayName) + .FirstOrDefault(); } /// @@ -193,12 +228,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// /// /// - [HttpPost("force-refresh")] - public async Task ForceRefresh(int seriesId) - { - await metadataService.ForceKavitaPlusRefresh(seriesId); - return Ok(); - } + // [HttpPost("force-refresh")] + // public async Task ForceRefresh(int seriesId) + // { + // await metadataService.ForceKavitaPlusRefresh(seriesId); + // return Ok(); + // } /// /// Fetches the details needed from Kavita+ for Series Detail page @@ -221,12 +256,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc return Ok(ret); } - private async Task PrepareSeriesDetail(List userReviews, SeriesDetailPlusDto ret) + private async Task PrepareSeriesDetail(List userReviews, SeriesDetailPlusDto? ret) { var isAdmin = User.IsInRole(PolicyConstants.AdminRole); var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId())!; - userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList())); + userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(ret.Reviews.ToList())); ret.Reviews = userReviews; if (!isAdmin && ret.Recommendations != null && user != null) @@ -235,12 +270,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc ret.Recommendations.OwnedSeries = await unitOfWork.SeriesRepository.GetSeriesDtoByIdsAsync( ret.Recommendations.OwnedSeries.Select(s => s.Id), user); - ret.Recommendations.ExternalSeries = new List(); + ret.Recommendations.ExternalSeries = []; } if (ret.Recommendations != null && user != null) { - ret.Recommendations.OwnedSeries ??= new List(); + ret.Recommendations.OwnedSeries ??= []; await unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, ret.Recommendations.OwnedSeries); } } diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 3466b5587..6e96c3063 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; +using System.Xml; using System.Xml.Serialization; using API.Comparators; using API.Data; @@ -14,6 +15,7 @@ using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.OPDS; +using API.DTOs.Person; using API.DTOs.Progress; using API.DTOs.Search; using API.Entities; @@ -26,6 +28,7 @@ using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using MimeTypes; namespace API.Controllers; @@ -35,6 +38,7 @@ namespace API.Controllers; [AllowAnonymous] public class OpdsController : BaseApiController { + private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly IDownloadService _downloadService; private readonly IDirectoryService _directoryService; @@ -81,7 +85,7 @@ public class OpdsController : BaseApiController IDirectoryService directoryService, ICacheService cacheService, IReaderService readerService, ISeriesService seriesService, IAccountService accountService, ILocalizationService localizationService, - IMapper mapper) + IMapper mapper, ILogger logger) { _unitOfWork = unitOfWork; _downloadService = downloadService; @@ -92,6 +96,7 @@ public class OpdsController : BaseApiController _accountService = accountService; _localizationService = localizationService; _mapper = mapper; + _logger = logger; _xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); @@ -579,19 +584,25 @@ public class OpdsController : BaseApiController public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0) { var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); - var (baseUrl, prefix) = await GetPrefix(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - var userWithLists = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user!.UserName!, AppUserIncludes.ReadingListsWithItems); - if (userWithLists == null) return Unauthorized(); - var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + { + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + } + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null) + { + return Unauthorized(); + } + + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, user.Id); if (readingList == null) { return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted")); } + var (baseUrl, prefix) = await GetPrefix(); var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix); SetFeedId(feed, $"reading-list-{readingListId}"); @@ -764,6 +775,12 @@ public class OpdsController : BaseApiController return CreateXmlResult(SerializeXml(feed)); } + /// + /// OPDS Search endpoint + /// + /// + /// + /// [HttpGet("{apiKey}/series")] [Produces("application/xml")] public async Task SearchSeries(string apiKey, [FromQuery] string query) @@ -781,20 +798,21 @@ public class OpdsController : BaseApiController query = query.Replace(@"%", string.Empty); // Get libraries user has access to var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); - if (!libraries.Any()) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted")); + if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - var series = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, libraries.Select(l => l.Id).ToArray(), query); + var searchResults = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, + libraries.Select(l => l.Id).ToArray(), query, includeChapterAndFiles: false); var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey, prefix); SetFeedId(feed, "search-series"); - foreach (var seriesDto in series.Series) + foreach (var seriesDto in searchResults.Series) { feed.Entries.Add(CreateSeries(seriesDto, apiKey, prefix, baseUrl)); } - foreach (var collection in series.Collections) + foreach (var collection in searchResults.Collections) { feed.Entries.Add(new FeedEntry() { @@ -813,7 +831,7 @@ public class OpdsController : BaseApiController }); } - foreach (var readingListDto in series.ReadingLists) + foreach (var readingListDto in searchResults.ReadingLists) { feed.Entries.Add(new FeedEntry() { @@ -827,6 +845,7 @@ public class OpdsController : BaseApiController }); } + // TODO: Search should allow Chapters/Files and more return CreateXmlResult(SerializeXml(feed)); } @@ -946,9 +965,7 @@ public class OpdsController : BaseApiController var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters); - // var chapters = - // (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)) - // .OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast); + var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix); SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s"); @@ -1094,15 +1111,6 @@ public class OpdsController : BaseApiController }; } - private static FeedAuthor CreateAuthor(PersonDto person) - { - return new FeedAuthor() - { - Name = person.Name, - Uri = "http://opds-spec.org/author/" + person.Id - }; - } - private static FeedEntry CreateSeries(SearchResultDto searchResultDto, string apiKey, string prefix, string baseUrl) { return new FeedEntry() @@ -1122,6 +1130,15 @@ public class OpdsController : BaseApiController }; } + private static FeedAuthor CreateAuthor(PersonDto person) + { + return new FeedAuthor() + { + Name = person.Name, + Uri = "http://opds-spec.org/author/" + person.Id + }; + } + private static FeedEntry CreateChapter(string apiKey, string title, string? summary, int chapterId, int volumeId, int seriesId, string prefix, string baseUrl) { return new FeedEntry() @@ -1357,9 +1374,48 @@ public class OpdsController : BaseApiController { if (feed == null) return string.Empty; + // Remove invalid XML characters from the feed object + SanitizeFeed(feed); + using var sm = new StringWriter(); _xmlSerializer.Serialize(sm, feed); - return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + var ret = sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + + return ret; + } + + // Recursively sanitize all string properties in the object + private static void SanitizeFeed(object? obj) + { + if (obj == null) return; + + var properties = obj.GetType().GetProperties(); + foreach (var property in properties) + { + // Skip properties that require an index (e.g., indexed collections) + if (property.GetIndexParameters().Length > 0) + continue; + + if (property.PropertyType == typeof(string) && property.CanWrite) + { + var value = (string?)property.GetValue(obj); + if (!string.IsNullOrEmpty(value)) + { + property.SetValue(obj, RemoveInvalidXmlChars(value)); + } + } + else if (property.PropertyType.IsClass) // Handle nested objects + { + var nestedObject = property.GetValue(obj); + if (nestedObject != null) + SanitizeFeed(nestedObject); + } + } + } + + private static string RemoveInvalidXmlChars(string input) + { + return new string(input.Where(XmlConvert.IsXmlChar).ToArray()); } } diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index fb18156ba..7328ff954 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -1,28 +1,47 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.DTOs; +using API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; +using API.DTOs.Person; using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Services; +using API.Services.Tasks.Metadata; +using API.SignalR; using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Nager.ArticleNumber; namespace API.Controllers; +#nullable enable public class PersonController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly ILocalizationService _localizationService; private readonly IMapper _mapper; + private readonly ICoverDbService _coverDbService; + private readonly IImageService _imageService; + private readonly IEventHub _eventHub; + private readonly IPersonService _personService; - public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper) + public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper, + ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService) { _unitOfWork = unitOfWork; _localizationService = localizationService; _mapper = mapper; + _coverDbService = coverDbService; + _imageService = imageService; + _eventHub = eventHub; + _personService = personService; } @@ -32,23 +51,42 @@ public class PersonController : BaseApiController return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId())); } - [HttpGet("roles")] - public async Task>> GetRolesForPersonByName(string name) + /// + /// Find a person by name or alias against a query string + /// + /// + /// + [HttpGet("search")] + public async Task>> SearchPeople([FromQuery] string queryString) { - return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(name, User.GetUserId())); + return Ok(await _unitOfWork.PersonRepository.SearchPeople(queryString)); } /// - /// Returns a list of authors for browsing + /// Returns all roles for a Person + /// + /// + /// + [HttpGet("roles")] + public async Task>> GetRolesForPersonByName(int personId) + { + return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId())); + } + + + /// + /// Returns a list of authors and artists for browsing /// /// /// - [HttpPost("authors")] - public async Task>> GetAuthorsForBrowse([FromQuery] UserParams? userParams) + [HttpPost("all")] + public async Task>> GetPeopleForBrowse(BrowsePersonFilterDto filter, [FromQuery] UserParams? userParams) { userParams ??= UserParams.Default; - var list = await _unitOfWork.PersonRepository.GetAllWritersAndSeriesCount(User.GetUserId(), userParams); + + var list = await _unitOfWork.PersonRepository.GetBrowsePersonDtos(User.GetUserId(), filter, userParams); Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + return Ok(list); } @@ -57,15 +95,30 @@ public class PersonController : BaseApiController /// /// /// + [Authorize("RequireAdminRole")] [HttpPost("update")] public async Task> UpdatePerson(UpdatePersonDto dto) { // This needs to get all people and update them equally - var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id); + var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id, PersonIncludes.Aliases); if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); - dto.Description ??= string.Empty; - person.Description = dto.Description; + if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required")); + + + // Validate the name is unique + if (dto.Name != person.Name && !(await _unitOfWork.PersonRepository.IsNameUnique(dto.Name))) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique")); + } + + var success = await _personService.UpdatePersonAliasesAsync(person, dto.Aliases); + if (!success) return BadRequest(await _localizationService.Translate(User.GetUserId(), "aliases-have-overlap")); + + + person.Name = dto.Name?.Trim(); + person.NormalizedName = person.Name.ToNormalized(); + person.Description = dto.Description ?? string.Empty; person.CoverImageLocked = dto.CoverImageLocked; if (dto.MalId is > 0) @@ -95,6 +148,35 @@ public class PersonController : BaseApiController return Ok(_mapper.Map(person)); } + /// + /// Attempts to download the cover from CoversDB (Note: Not yet release in Kavita) + /// + /// + /// + [HttpPost("fetch-cover")] + public async Task> DownloadCoverImage([FromQuery] int personId) + { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var person = await _unitOfWork.PersonRepository.GetPersonById(personId); + if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); + + var personImage = await _coverDbService.DownloadPersonImageAsync(person, settings.EncodeMediaAs); + + if (string.IsNullOrEmpty(personImage)) + { + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-image-doesnt-exist")); + } + + person.CoverImage = personImage; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(person.Id, "person"), false); + + return Ok(personImage); + } + /// /// Returns the top 20 series that the "person" is known for. This will use Average Rating when applicable (Kavita+ field), else it's a random sort /// @@ -103,14 +185,57 @@ public class PersonController : BaseApiController [HttpGet("series-known-for")] public async Task>> GetKnownSeries(int personId) { - return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId)); + return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId, User.GetUserId())); } + /// + /// Returns all individual chapters by role. Limited to 20 results. + /// + /// + /// + /// [HttpGet("chapters-by-role")] public async Task>> GetChaptersByRole(int personId, PersonRole role) { return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role)); } + /// + /// Merges Persons into one, this action is irreversible + /// + /// + /// + [HttpPost("merge")] + [Authorize("RequireAdminRole")] + public async Task> MergePeople(PersonMergeDto dto) + { + var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All); + if (dst == null) return BadRequest(); + + var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId, PersonIncludes.All); + if (src == null) return BadRequest(); + + await _personService.MergePeopleAsync(src, dst); + await _eventHub.SendMessageAsync(MessageFactory.PersonMerged, MessageFactory.PersonMergedMessage(dst, src)); + + return Ok(_mapper.Map(dst)); + } + + /// + /// Ensure the alias is valid to be added. For example, the alias cannot be on another person or be the same as the current person name/alias. + /// + /// + /// + /// + [HttpGet("valid-alias")] + public async Task> IsValidAlias(int personId, string alias) + { + var person = await _unitOfWork.PersonRepository.GetPersonById(personId, PersonIncludes.Aliases); + if (person == null) return NotFound(); + + var existingAlias = await _unitOfWork.PersonRepository.AnyAliasExist(alias); + return Ok(!existingAlias && person.NormalizedName != alias.ToNormalized()); + } + } diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index 87cfaf2c2..f39462bbf 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -30,7 +30,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService public async Task> Authenticate([Required] string apiKey, [Required] string pluginName) { // NOTE: In order to log information about plugins, we need some Plugin Description information for each request - // Should log into access table so we can tell the user + // Should log into the access table so we can tell the user var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var userAgent = HttpContext.Request.Headers.UserAgent; var userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -45,7 +45,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService throw new KavitaUnauthenticatedUserException(); } var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId); - logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId); + logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({AppUserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId); return new UserDto { diff --git a/API/Controllers/RatingController.cs b/API/Controllers/RatingController.cs index a40b6680b..9283ef6d3 100644 --- a/API/Controllers/RatingController.cs +++ b/API/Controllers/RatingController.cs @@ -1,15 +1,12 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using API.Constants; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.Extensions; +using API.Services; using API.Services.Plus; -using EasyCaching.Core; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -21,21 +18,85 @@ namespace API.Controllers; public class RatingController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly IRatingService _ratingService; + private readonly ILocalizationService _localizationService; - public RatingController(IUnitOfWork unitOfWork) + public RatingController(IUnitOfWork unitOfWork, IRatingService ratingService, ILocalizationService localizationService) { _unitOfWork = unitOfWork; - + _ratingService = ratingService; + _localizationService = localizationService; } - [HttpGet("overall")] - public async Task> GetOverallRating(int seriesId) + /// + /// Update the users' rating of the given series + /// + /// + /// + /// + [HttpPost("series")] + public async Task UpdateSeriesRating(UpdateRatingDto updateRating) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings | AppUserIncludes.ChapterRatings); + if (user == null) throw new UnauthorizedAccessException(); + + if (await _ratingService.UpdateSeriesRating(user, updateRating)) + { + return Ok(); + } + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); + } + + /// + /// Update the users' rating of the given chapter + /// + /// chapterId must be set + /// + /// + [HttpPost("chapter")] + public async Task UpdateChapterRating(UpdateRatingDto updateRating) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings | AppUserIncludes.ChapterRatings); + if (user == null) throw new UnauthorizedAccessException(); + + if (await _ratingService.UpdateChapterRating(user, updateRating)) + { + return Ok(); + } + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); + } + + /// + /// Overall rating from all Kavita users for a given Series + /// + /// + /// + [HttpGet("overall-series")] + public async Task> GetOverallSeriesRating(int seriesId) { return Ok(new RatingDto() { Provider = ScrobbleProvider.Kavita, AverageScore = await _unitOfWork.SeriesRepository.GetAverageUserRating(seriesId, User.GetUserId()), - FavoriteCount = 0 + FavoriteCount = 0, + }); + } + + /// + /// Overall rating from all Kavita users for a given Chapter + /// + /// + /// + [HttpGet("overall-chapter")] + public async Task> GetOverallChapterRating(int chapterId) + { + return Ok(new RatingDto() + { + Provider = ScrobbleProvider.Kavita, + AverageScore = await _unitOfWork.ChapterRepository.GetAverageUserRating(chapterId, User.GetUserId()), + FavoriteCount = 0, }); } } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index fe96e3841..38a5ad482 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -21,7 +21,6 @@ using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Tokens; using MimeTypes; namespace API.Controllers; @@ -68,11 +67,11 @@ public class ReaderController : BaseApiController /// /// [HttpGet("pdf")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "apiKey"})] - public async Task GetPdf(int chapterId, string apiKey) + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "apiKey"])] + public async Task GetPdf(int chapterId, string apiKey, bool extractPdf = false) { if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); - var chapter = await _cacheService.Ensure(chapterId); + var chapter = await _cacheService.Ensure(chapterId, extractPdf); if (chapter == null) return NoContent(); // Validate the user has access to the PDF @@ -90,7 +89,7 @@ public class ReaderController : BaseApiController } catch (Exception) { - _cacheService.CleanupChapters(new []{ chapterId }); + _cacheService.CleanupChapters([chapterId]); throw; } } @@ -105,7 +104,8 @@ public class ReaderController : BaseApiController /// Should Kavita extract pdf into images. Defaults to false. /// [HttpGet("image")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "page", "extractPdf", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "page", "extractPdf", "apiKey" + ])] [AllowAnonymous] public async Task GetImage(int chapterId, int page, string apiKey, bool extractPdf = false) { @@ -117,7 +117,7 @@ public class ReaderController : BaseApiController { var chapter = await _cacheService.Ensure(chapterId, extractPdf); if (chapter == null) return NoContent(); - _logger.LogInformation("Fetching Page {PageNum} on Chapter {ChapterId}", page, chapterId); + var path = _cacheService.GetCachedPagePath(chapter.Id, page); if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest(await _localizationService.Translate(userId, "no-image-for-page", page)); @@ -127,7 +127,7 @@ public class ReaderController : BaseApiController } catch (Exception) { - _cacheService.CleanupChapters(new []{ chapterId }); + _cacheService.CleanupChapters([chapterId]); throw; } } @@ -140,7 +140,7 @@ public class ReaderController : BaseApiController /// /// [HttpGet("thumbnail")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "pageNum", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey"])] [AllowAnonymous] public async Task GetThumbnail(int chapterId, int pageNum, string apiKey) { @@ -164,14 +164,14 @@ public class ReaderController : BaseApiController /// We must use api key as bookmarks could be leaked to other users via the API /// [HttpGet("bookmark-image")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"seriesId", "page", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "page", "apiKey"])] [AllowAnonymous] public async Task GetBookmarkImage(int seriesId, string apiKey, int page) { - if (page < 0) page = 0; var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId == 0) return Unauthorized(); + if (page < 0) page = 0; var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId); if (page > totalPages) { @@ -188,7 +188,7 @@ public class ReaderController : BaseApiController } catch (Exception) { - _cacheService.CleanupBookmarks(new []{ seriesId }); + _cacheService.CleanupBookmarks([seriesId]); throw; } } @@ -202,12 +202,13 @@ public class ReaderController : BaseApiController /// /// [HttpGet("file-dimensions")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "extractPdf"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf"])] public async Task>> GetFileDimensions(int chapterId, bool extractPdf = false) { if (chapterId <= 0) return ArraySegment.Empty; var chapter = await _cacheService.Ensure(chapterId, extractPdf); if (chapter == null) return NoContent(); + return Ok(_cacheService.GetCachedFileDimensions(_cacheService.GetCachePath(chapterId))); } @@ -220,7 +221,8 @@ public class ReaderController : BaseApiController /// Include file dimensions. Only useful for image based reading /// [HttpGet("chapter-info")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "extractPdf", "includeDimensions"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions" + ])] public async Task> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false) { if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore @@ -260,6 +262,7 @@ public class ReaderController : BaseApiController } if (info.ChapterTitle is {Length: > 0}) { + // TODO: Can we rework the logic of generating titles for the UI and instead calculate that in the DB? info.Title += " - " + info.ChapterTitle; } @@ -291,7 +294,7 @@ public class ReaderController : BaseApiController /// Include file dimensions (extra I/O). Defaults to true. /// [HttpGet("bookmark-info")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"seriesId", "includeDimensions"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "includeDimensions"])] public async Task> GetBookmarkInfo(int seriesId, bool includeDimensions = true) { var totalPages = await _cacheService.CacheBookmarkForSeries(User.GetUserId(), seriesId); @@ -375,13 +378,10 @@ public class ReaderController : BaseApiController var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); await _readerService.MarkChaptersAsUnread(user, markVolumeReadDto.SeriesId, chapters); - if (await _unitOfWork.CommitAsync()) - { - BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markVolumeReadDto.SeriesId)); - return Ok(); - } + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); - return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); + BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markVolumeReadDto.SeriesId)); + return Ok(); } /// @@ -540,6 +540,8 @@ public class ReaderController : BaseApiController public async Task> GetProgress(int chapterId) { var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, User.GetUserId()); + _logger.LogDebug("Get Progress for {ChapterId} is {Pages}", chapterId, progress?.PageNum ?? 0); + if (progress == null) return Ok(new ProgressDto() { PageNum = 0, @@ -551,7 +553,7 @@ public class ReaderController : BaseApiController } /// - /// Save page against Chapter for logged in user + /// Save page against Chapter for authenticated user /// /// /// @@ -769,7 +771,7 @@ public class ReaderController : BaseApiController /// /// /// chapter id for next manga - [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] { "seriesId", "volumeId", "currentChapterId"})] + [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId", "volumeId", "currentChapterId"])] [HttpGet("next-chapter")] public async Task> GetNextChapter(int seriesId, int volumeId, int currentChapterId) { @@ -787,7 +789,7 @@ public class ReaderController : BaseApiController /// /// /// chapter id for next manga - [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] { "seriesId", "volumeId", "currentChapterId"})] + [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId", "volumeId", "currentChapterId"])] [HttpGet("prev-chapter")] public async Task> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId) { @@ -801,7 +803,7 @@ public class ReaderController : BaseApiController /// /// [HttpGet("time-left")] - [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] { "seriesId"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId"])] public async Task> GetEstimateToCompletion(int seriesId) { var userId = User.GetUserId(); @@ -899,12 +901,8 @@ public class ReaderController : BaseApiController [HttpGet("all-chapter-progress")] public async Task>> GetProgressForChapter(int chapterId) { - if (User.IsInRole(PolicyConstants.AdminRole)) - { - return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId)); - } - - return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, User.GetUserId())); + var userId = User.IsInRole(PolicyConstants.AdminRole) ? 0 : User.GetUserId(); + return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, userId)); } } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 6c23f5652..1187992bc 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -4,12 +4,12 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Repositories; -using API.DTOs; +using API.DTOs.Person; using API.DTOs.ReadingLists; +using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Services; -using API.SignalR; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,13 +24,15 @@ public class ReadingListController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IReadingListService _readingListService; private readonly ILocalizationService _localizationService; + private readonly IReaderService _readerService; public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService, - ILocalizationService localizationService) + ILocalizationService localizationService, IReaderService readerService) { _unitOfWork = unitOfWork; _readingListService = readingListService; _localizationService = localizationService; + _readerService = readerService; } /// @@ -39,9 +41,15 @@ public class ReadingListController : BaseApiController /// /// [HttpGet] - public async Task>> GetList(int readingListId) + public async Task> GetList(int readingListId) { - return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, User.GetUserId())); + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, User.GetUserId()); + if (readingList == null) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-restricted")); + } + + return Ok(readingList); } /// @@ -108,6 +116,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-position")] public async Task UpdateListItemPosition(UpdateReadingListPosition dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); // Make sure UI buffers events var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) @@ -122,13 +131,14 @@ public class ReadingListController : BaseApiController } /// - /// Deletes a list item from the list. Will reorder all item positions afterwards + /// Deletes a list item from the list. Item orders will update as a result. /// /// /// [HttpPost("delete-item")] public async Task DeleteListItem(UpdateReadingListPosition dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -151,6 +161,8 @@ public class ReadingListController : BaseApiController [HttpPost("remove-read")] public async Task DeleteReadFromList([FromQuery] int readingListId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { @@ -162,7 +174,7 @@ public class ReadingListController : BaseApiController return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } - return BadRequest("Couldn't delete item(s)"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-item-delete")); } /// @@ -173,6 +185,7 @@ public class ReadingListController : BaseApiController [HttpDelete] public async Task DeleteList([FromQuery] int readingListId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { @@ -193,6 +206,7 @@ public class ReadingListController : BaseApiController [HttpPost("create")] public async Task> CreateList(CreateReadingListDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.ReadingLists); if (user == null) return Unauthorized(); @@ -216,6 +230,7 @@ public class ReadingListController : BaseApiController [HttpPost("update")] public async Task UpdateList(UpdateReadingListDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); @@ -245,6 +260,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-series")] public async Task UpdateListBySeries(UpdateReadingListBySeriesDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -254,7 +270,7 @@ public class ReadingListController : BaseApiController var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); var chapterIdsForSeries = - await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId}); + await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync([dto.SeriesId]); // If there are adds, tell tracking this has been modified if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, readingList)) @@ -287,6 +303,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-multiple")] public async Task UpdateListByMultiple(UpdateReadingListByMultipleDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -331,6 +348,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-multiple-series")] public async Task UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -369,6 +387,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-volume")] public async Task UpdateListByVolume(UpdateReadingListByVolumeDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -405,6 +424,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-chapter")] public async Task UpdateListByChapter(UpdateReadingListByChapterDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -435,26 +455,38 @@ public class ReadingListController : BaseApiController return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); } + /// - /// Returns a list of characters associated with the reading list + /// Returns a list of a given role associated with the reading list + /// + /// + /// PersonRole + /// + [HttpGet("people")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId", "role"])] + public ActionResult> GetPeopleByRoleForList(int readingListId, PersonRole role) + { + return Ok(_unitOfWork.ReadingListRepository.GetReadingListPeopleAsync(readingListId, role)); + } + + /// + /// Returns all people in given roles for a reading list /// /// /// - [HttpGet("characters")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)] - public ActionResult> GetCharactersForList(int readingListId) + [HttpGet("all-people")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId"])] + public async Task>> GetAllPeopleForList(int readingListId) { - return Ok(_unitOfWork.ReadingListRepository.GetReadingListCharactersAsync(readingListId)); + return Ok(await _unitOfWork.ReadingListRepository.GetReadingListAllPeopleAsync(readingListId)); } - - /// /// Returns the next chapter within the reading list /// /// /// - /// Chapter Id for next item, -1 if nothing exists + /// Chapter ID for next item, -1 if nothing exists [HttpGet("next-chapter")] public async Task> GetNextChapter(int currentChapterId, int readingListId) { @@ -514,6 +546,8 @@ public class ReadingListController : BaseApiController [HttpPost("promote-multiple")] public async Task PromoteMultipleReadingLists(PromoteReadingListsDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + // This needs to take into account owner as I can select other users cards var userId = User.GetUserId(); if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole)) @@ -558,4 +592,26 @@ public class ReadingListController : BaseApiController return Ok(); } + + /// + /// Returns random information about a Reading List + /// + /// + /// + [HttpGet("info")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["readingListId"])] + public async Task> GetReadingListInfo(int readingListId) + { + var result = await _unitOfWork.ReadingListRepository.GetReadingListInfoAsync(readingListId); + + if (result == null) return Ok(null); + + var timeEstimate = _readerService.GetTimeEstimate(result.WordCount, result.Pages, result.IsAllEpub); + + result.MinHoursToRead = timeEstimate.MinHours; + result.AvgHoursToRead = timeEstimate.AvgHours; + result.MaxHoursToRead = timeEstimate.MaxHours; + + return Ok(result); + } } diff --git a/API/Controllers/ReadingProfileController.cs b/API/Controllers/ReadingProfileController.cs new file mode 100644 index 000000000..bc1b4fa52 --- /dev/null +++ b/API/Controllers/ReadingProfileController.cs @@ -0,0 +1,198 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Extensions; +using API.Services; +using AutoMapper; +using Kavita.Common; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Controllers; + +[Route("api/reading-profile")] +public class ReadingProfileController(ILogger logger, IUnitOfWork unitOfWork, + IReadingProfileService readingProfileService): BaseApiController +{ + + /// + /// Gets all non-implicit reading profiles for a user + /// + /// + [HttpGet("all")] + public async Task>> GetAllReadingProfiles() + { + return Ok(await unitOfWork.AppUserReadingProfileRepository.GetProfilesDtoForUser(User.GetUserId(), true)); + } + + /// + /// Returns the ReadingProfile that should be applied to the given series, walks up the tree. + /// Series -> Library -> Default + /// + /// + /// + /// + [HttpGet("{seriesId:int}")] + public async Task> GetProfileForSeries(int seriesId, [FromQuery] bool skipImplicit) + { + return Ok(await readingProfileService.GetReadingProfileDtoForSeries(User.GetUserId(), seriesId, skipImplicit)); + } + + /// + /// Returns the (potential) Reading Profile bound to the library + /// + /// + /// + [HttpGet("library")] + public async Task> GetProfileForLibrary(int libraryId) + { + return Ok(await readingProfileService.GetReadingProfileDtoForLibrary(User.GetUserId(), libraryId)); + } + + /// + /// Creates a new reading profile for the current user + /// + /// + /// + [HttpPost("create")] + public async Task> CreateReadingProfile([FromBody] UserReadingProfileDto dto) + { + return Ok(await readingProfileService.CreateReadingProfile(User.GetUserId(), dto)); + } + + /// + /// Promotes the implicit profile to a user profile. Removes the series from other profiles + /// + /// + /// + [HttpPost("promote")] + public async Task> PromoteImplicitReadingProfile([FromQuery] int profileId) + { + return Ok(await readingProfileService.PromoteImplicitProfile(User.GetUserId(), profileId)); + } + + /// + /// Update the implicit reading profile for a series, creates one if none exists + /// + /// Any modification to the reader settings during reading will create an implicit profile. Use "update-parent" to save to the bound series profile. + /// + /// + /// + [HttpPost("series")] + public async Task> UpdateReadingProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId) + { + var updatedProfile = await readingProfileService.UpdateImplicitReadingProfile(User.GetUserId(), seriesId, dto); + return Ok(updatedProfile); + } + + /// + /// Updates the non-implicit reading profile for the given series, and removes implicit profiles + /// + /// + /// + /// + [HttpPost("update-parent")] + public async Task> UpdateParentProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId) + { + var newParentProfile = await readingProfileService.UpdateParent(User.GetUserId(), seriesId, dto); + return Ok(newParentProfile); + } + + /// + /// Updates the given reading profile, must belong to the current user + /// + /// + /// The updated reading profile + /// + /// This does not update connected series and libraries. + /// + [HttpPost] + public async Task> UpdateReadingProfile(UserReadingProfileDto dto) + { + return Ok(await readingProfileService.UpdateReadingProfile(User.GetUserId(), dto)); + } + + /// + /// Deletes the given profile, requires the profile to belong to the logged-in user + /// + /// + /// + /// + /// + [HttpDelete] + public async Task DeleteReadingProfile([FromQuery] int profileId) + { + await readingProfileService.DeleteReadingProfile(User.GetUserId(), profileId); + return Ok(); + } + + /// + /// Sets the reading profile for a given series, removes the old one + /// + /// + /// + /// + [HttpPost("series/{seriesId:int}")] + public async Task AddProfileToSeries(int seriesId, [FromQuery] int profileId) + { + await readingProfileService.AddProfileToSeries(User.GetUserId(), profileId, seriesId); + return Ok(); + } + + /// + /// Clears the reading profile for the given series for the currently logged-in user + /// + /// + /// + [HttpDelete("series/{seriesId:int}")] + public async Task ClearSeriesProfile(int seriesId) + { + await readingProfileService.ClearSeriesProfile(User.GetUserId(), seriesId); + return Ok(); + } + + /// + /// Sets the reading profile for a given library, removes the old one + /// + /// + /// + /// + [HttpPost("library/{libraryId:int}")] + public async Task AddProfileToLibrary(int libraryId, [FromQuery] int profileId) + { + await readingProfileService.AddProfileToLibrary(User.GetUserId(), profileId, libraryId); + return Ok(); + } + + /// + /// Clears the reading profile for the given library for the currently logged-in user + /// + /// + /// + /// + [HttpDelete("library/{libraryId:int}")] + public async Task ClearLibraryProfile(int libraryId) + { + await readingProfileService.ClearLibraryProfile(User.GetUserId(), libraryId); + return Ok(); + } + + /// + /// Assigns the reading profile to all passes series, and deletes their implicit profiles + /// + /// + /// + /// + [HttpPost("bulk")] + public async Task BulkAddReadingProfile([FromQuery] int profileId, [FromBody] IList seriesIds) + { + await readingProfileService.BulkAddProfileToSeries(User.GetUserId(), profileId, seriesIds); + return Ok(); + } + +} diff --git a/API/Controllers/ReviewController.cs b/API/Controllers/ReviewController.cs index ae8ce02ee..d4de3db16 100644 --- a/API/Controllers/ReviewController.cs +++ b/API/Controllers/ReviewController.cs @@ -1,8 +1,11 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs.SeriesDetail; +using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Helpers.Builders; using API.Services.Plus; @@ -30,17 +33,17 @@ public class ReviewController : BaseApiController /// - /// Updates the review for a given series + /// Updates the user's review for a given series /// /// /// - [HttpPost] - public async Task> UpdateReview(UpdateUserReviewDto dto) + [HttpPost("series")] + public async Task> UpdateSeriesReview(UpdateUserReviewDto dto) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings); if (user == null) return Unauthorized(); - var ratingBuilder = new RatingBuilder(user.Ratings.FirstOrDefault(r => r.SeriesId == dto.SeriesId)); + var ratingBuilder = new RatingBuilder(await _unitOfWork.UserRepository.GetUserRatingAsync(dto.SeriesId, user.Id)); var rating = ratingBuilder .WithBody(dto.Body) @@ -52,22 +55,58 @@ public class ReviewController : BaseApiController { user.Ratings.Add(rating); } + _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); - BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReviewUpdate(user.Id, dto.SeriesId, string.Empty, dto.Body)); return Ok(_mapper.Map(rating)); } + /// + /// Update the user's review for a given chapter + /// + /// chapterId must be set + /// + [HttpPost("chapter")] + public async Task> UpdateChapterReview(UpdateUserReviewDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ChapterRatings); + if (user == null) return Unauthorized(); + + if (dto.ChapterId == null) return BadRequest(); + + int chapterId = dto.ChapterId.Value; + + var ratingBuilder = new ChapterRatingBuilder(await _unitOfWork.UserRepository.GetUserChapterRatingAsync(user.Id, chapterId)); + + var rating = ratingBuilder + .WithBody(dto.Body) + .WithSeriesId(dto.SeriesId) + .WithChapterId(chapterId) + .Build(); + + if (rating.Id == 0) + { + user.ChapterRatings.Add(rating); + } + + _unitOfWork.UserRepository.Update(user); + + await _unitOfWork.CommitAsync(); + + return Ok(_mapper.Map(rating)); + } + + /// /// Deletes the user's review for the given series /// /// - [HttpDelete] - public async Task DeleteReview(int seriesId) + [HttpDelete("series")] + public async Task DeleteSeriesReview([FromQuery] int seriesId) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings); if (user == null) return Unauthorized(); @@ -80,4 +119,23 @@ public class ReviewController : BaseApiController return Ok(); } + + /// + /// Deletes the user's review for the given chapter + /// + /// + [HttpDelete("chapter")] + public async Task DeleteChapterReview([FromQuery] int chapterId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ChapterRatings); + if (user == null) return Unauthorized(); + + user.ChapterRatings = user.ChapterRatings.Where(r => r.ChapterId != chapterId).ToList(); + + _unitOfWork.UserRepository.Update(user); + + await _unitOfWork.CommitAsync(); + + return Ok(); + } } diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs index 685f3e2a1..986f4f8e7 100644 --- a/API/Controllers/ScrobblingController.cs +++ b/API/Controllers/ScrobblingController.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs.Account; +using API.DTOs.KavitaPlus.Account; using API.DTOs.Scrobbling; using API.Entities.Scrobble; using API.Extensions; @@ -53,7 +54,7 @@ public class ScrobblingController : BaseApiController } /// - /// Get the current user's MAL token & username + /// Get the current user's MAL token and username /// /// [HttpGet("mal-token")] @@ -73,9 +74,9 @@ public class ScrobblingController : BaseApiController /// Update the current user's AniList token /// /// - /// + /// True if the token was new or not [HttpPost("update-anilist-token")] - public async Task UpdateAniListToken(AniListUpdateDto dto) + public async Task> UpdateAniListToken(AniListUpdateDto dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); @@ -85,31 +86,39 @@ public class ScrobblingController : BaseApiController _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); - if (isNewToken) - { - BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistory(user.Id)); - } - - return Ok(); + return Ok(isNewToken); } /// /// Update the current user's MAL token (Client ID) and Username /// /// - /// + /// True if the token was new or not [HttpPost("update-mal-token")] - public async Task UpdateMalToken(MalUserInfoDto dto) + public async Task> UpdateMalToken(MalUserInfoDto dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); + var isNewToken = string.IsNullOrEmpty(user.MalAccessToken); user.MalAccessToken = dto.AccessToken; user.MalUserName = dto.Username; _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); + return Ok(isNewToken); + } + + /// + /// When a user request to generate scrobble events from history. Should only be ran once per user. + /// + /// + [HttpPost("generate-scrobble-events")] + public ActionResult GenerateScrobbleEvents() + { + BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistory(User.GetUserId())); + return Ok(); } @@ -245,7 +254,7 @@ public class ScrobblingController : BaseApiController } /// - /// Adds a hold against the Series for user's scrobbling + /// Remove a hold against the Series for user's scrobbling /// /// /// @@ -261,4 +270,29 @@ public class ScrobblingController : BaseApiController await _unitOfWork.CommitAsync(); return Ok(); } + + /// + /// Has the logged in user ran scrobble generation + /// + /// + [HttpGet("has-ran-scrobble-gen")] + public async Task> HasRanScrobbleGen() + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId()); + return Ok(user is {HasRunScrobbleEventGeneration: true}); + } + + /// + /// Delete the given scrobble events if they belong to that user + /// + /// + /// + [HttpPost("bulk-remove-events")] + public async Task BulkRemoveScrobbleEvents(IList eventIds) + { + var events = await _unitOfWork.ScrobbleRepository.GetUserEvents(User.GetUserId(), eventIds); + _unitOfWork.ScrobbleRepository.Remove(events); + await _unitOfWork.CommitAsync(); + return Ok(); + } } diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index 5aa54d1db..cc89a124e 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -63,6 +63,7 @@ public class SearchController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); + var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList(); if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index c5acd4bf1..389ff33a7 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -9,20 +9,24 @@ using API.DTOs.Dashboard; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.Metadata; +using API.DTOs.Metadata.Matching; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.MetadataMatching; using API.Extensions; using API.Helpers; using API.Services; using API.Services.Plus; using EasyCaching.Core; +using Hangfire; using Kavita.Common; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -38,14 +42,17 @@ public class SeriesController : BaseApiController private readonly ILicenseService _licenseService; private readonly ILocalizationService _localizationService; private readonly IExternalMetadataService _externalMetadataService; + private readonly IHostEnvironment _environment; private readonly IEasyCachingProvider _externalSeriesCacheProvider; + private readonly IEasyCachingProvider _matchSeriesCacheProvider; private const string CacheKey = "externalSeriesData_"; + private const string MatchSeriesCacheKey = "matchSeries_"; public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService, ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService, - IExternalMetadataService externalMetadataService) + IExternalMetadataService externalMetadataService, IHostEnvironment environment) { _logger = logger; _taskScheduler = taskScheduler; @@ -54,8 +61,10 @@ public class SeriesController : BaseApiController _licenseService = licenseService; _localizationService = localizationService; _externalMetadataService = externalMetadataService; + _environment = environment; _externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries); + _matchSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusMatchSeries); } /// @@ -91,7 +100,7 @@ public class SeriesController : BaseApiController /// /// [HttpPost("v2")] - public async Task>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto) + public async Task>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto) { var userId = User.GetUserId(); var series = @@ -134,7 +143,7 @@ public class SeriesController : BaseApiController var username = User.GetUsername(); _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username); - return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId})); + return Ok(await _seriesService.DeleteMultipleSeries([seriesId])); } [Authorize(Policy = "RequireAdminRole")] @@ -183,21 +192,6 @@ public class SeriesController : BaseApiController return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId)); } - - /// - /// Update the user rating for the given series - /// - /// - /// - [HttpPost("update-rating")] - public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings); - if (!await _seriesService.UpdateRating(user!, updateSeriesRatingDto)) - return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); - return Ok(); - } - /// /// Updates the Series /// @@ -230,7 +224,9 @@ public class SeriesController : BaseApiController // Trigger a refresh when we are moving from a locked image to a non-locked needsRefreshMetadata = true; series.CoverImage = null; - series.CoverImageLocked = updateSeries.CoverImageLocked; + series.CoverImageLocked = false; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Covers); + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); series.ResetColorScape(); } @@ -316,7 +312,7 @@ public class SeriesController : BaseApiController /// /// /// - /// + /// This is not in use /// [HttpPost("all-v2")] public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, @@ -327,8 +323,6 @@ public class SeriesController : BaseApiController await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context); // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); @@ -344,7 +338,7 @@ public class SeriesController : BaseApiController /// /// [HttpPost("all")] - [Obsolete("User all-v2")] + [Obsolete("Use all-v2")] public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var userId = User.GetUserId(); @@ -500,7 +494,7 @@ public class SeriesController : BaseApiController /// /// /// This is cached for an hour - [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})] + [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = ["ageRating"])] [HttpGet("age-rating")] public async Task> GetAgeRating(int ageRating) { @@ -616,4 +610,52 @@ public class SeriesController : BaseApiController return Ok(await _seriesService.GetEstimatedChapterCreationDate(seriesId, userId)); } + /// + /// Sends a request to Kavita+ API for all potential matches, sorted by relevance + /// + /// + /// + [HttpPost("match")] + public async Task>> MatchSeries(MatchSeriesDto dto) + { + var cacheKey = $"{MatchSeriesCacheKey}-{dto.SeriesId}-{dto.Query}"; + var results = await _matchSeriesCacheProvider.GetAsync>(cacheKey); + if (results.HasValue && !_environment.IsDevelopment()) + { + return Ok(results.Value); + } + + var ret = await _externalMetadataService.MatchSeries(dto); + await _matchSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(1)); + + return Ok(ret); + } + + /// + /// This will perform the fix match + /// + /// + /// + /// + [HttpPost("update-match")] + public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int? aniListId, [FromQuery] long? malId, [FromQuery] int? cbrId) + { + BackgroundJob.Enqueue(() => _externalMetadataService.FixSeriesMatch(seriesId, aniListId, malId, cbrId)); + + return Ok(); + } + + /// + /// When true, will not perform a match and will prevent Kavita from attempting to match/scrobble against this series + /// + /// + /// + /// + [HttpPost("dont-match")] + public async Task UpdateDontMatch([FromQuery] int seriesId, [FromQuery] bool dontMatch) + { + await _externalMetadataService.UpdateSeriesDontMatch(seriesId, dontMatch); + return Ok(); + } + } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 7eb7fe910..79f6391e8 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -129,15 +129,6 @@ public class ServerController : BaseApiController return Ok(); } - /// - /// Returns non-sensitive information about the current system - /// - /// - [HttpGet("server-info")] - public async Task> GetVersion() - { - return Ok(await _statsService.GetServerInfo()); - } /// /// Returns non-sensitive information about the current system @@ -145,7 +136,7 @@ public class ServerController : BaseApiController /// This is just for the UI and is extremely lightweight /// [HttpGet("server-info-slim")] - public async Task> GetSlimVersion() + public async Task> GetSlimVersion() { return Ok(await _statsService.GetServerInfoSlim()); } @@ -212,25 +203,27 @@ public class ServerController : BaseApiController /// /// Returns how many versions out of date this install is /// + /// Only count Stable releases [HttpGet("check-out-of-date")] - public async Task> CheckHowOutOfDate() + public async Task> CheckHowOutOfDate(bool stableOnly = true) { - return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind()); + return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind(stableOnly)); } /// /// Pull the Changelog for Kavita from Github and display /// + /// How many releases from the latest to return /// [AllowAnonymous] [HttpGet("changelog")] - public async Task>> GetChangelog() + public async Task>> GetChangelog(int count = 0) { // Strange bug where [Authorize] doesn't work if (User.GetUserId() == 0) return Unauthorized(); - return Ok(await _versionUpdaterService.GetAllReleases()); + return Ok(await _versionUpdaterService.GetAllReleases(count)); } /// diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index d884d05c8..0610c8705 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -5,6 +5,7 @@ using System.Net; using System.Threading.Tasks; using API.Data; using API.DTOs.Email; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; @@ -33,27 +34,26 @@ public class SettingsController : BaseApiController { private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; - private readonly ITaskScheduler _taskScheduler; - private readonly IDirectoryService _directoryService; private readonly IMapper _mapper; private readonly IEmailService _emailService; - private readonly ILibraryWatcher _libraryWatcher; private readonly ILocalizationService _localizationService; + private readonly ISettingsService _settingsService; - public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, - IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher, - ILocalizationService localizationService) + public SettingsController(ILogger logger, IUnitOfWork unitOfWork, IMapper mapper, + IEmailService emailService, ILocalizationService localizationService, ISettingsService settingsService) { _logger = logger; _unitOfWork = unitOfWork; - _taskScheduler = taskScheduler; - _directoryService = directoryService; _mapper = mapper; _emailService = emailService; - _libraryWatcher = libraryWatcher; _localizationService = localizationService; + _settingsService = settingsService; } + /// + /// Returns the base url for this instance (if set) + /// + /// [HttpGet("base-url")] public async Task> GetBaseUrl() { @@ -138,335 +138,33 @@ public class SettingsController : BaseApiController } - + /// + /// Update Server settings + /// + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpPost] public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) { _logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername()); - // We do not allow CacheDirectory changes, so we will ignore. - var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); - var updateBookmarks = false; - var originalBookmarkDirectory = _directoryService.BookmarkDirectory; - - var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; - if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && - !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) - { - bookmarkDirectory = - _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); - } - - if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) - { - bookmarkDirectory = _directoryService.BookmarkDirectory; - } - - foreach (var setting in currentSettings) - { - if (setting.Key == ServerSettingKey.OnDeckProgressDays && - updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.OnDeckUpdateDays && - updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.CoverImageSize && - updateSettingsDto.CoverImageSize + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.CoverImageSize + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) - { - if (OsInfo.IsDocker) continue; - setting.Value = updateSettingsDto.Port + string.Empty; - // Port is managed in appSetting.json - Configuration.Port = updateSettingsDto.Port; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.CacheSize && - updateSettingsDto.CacheSize + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.CacheSize + string.Empty; - // CacheSize is managed in appSetting.json - Configuration.CacheSize = updateSettingsDto.CacheSize; - _unitOfWork.SettingsRepository.Update(setting); - } - - UpdateSchedulingSettings(setting, updateSettingsDto); - - UpdateEmailSettings(setting, updateSettingsDto); - - - - if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) - { - if (OsInfo.IsDocker) continue; - // Validate IP addresses - foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', - StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - if (!IPAddress.TryParse(ipAddress.Trim(), out _)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "ip-address-invalid", - ipAddress)); - } - } - - setting.Value = updateSettingsDto.IpAddresses; - // IpAddresses is managed in appSetting.json - Configuration.IpAddresses = updateSettingsDto.IpAddresses; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) - { - var path = !updateSettingsDto.BaseUrl.StartsWith('/') - ? $"/{updateSettingsDto.BaseUrl}" - : updateSettingsDto.BaseUrl; - path = !path.EndsWith('/') - ? $"{path}/" - : path; - setting.Value = path; - Configuration.BaseUrl = updateSettingsDto.BaseUrl; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.LoggingLevel && - updateSettingsDto.LoggingLevel + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.LoggingLevel + string.Empty; - LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EnableOpds && - updateSettingsDto.EnableOpds + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EnableOpds + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EncodeMediaAs && - updateSettingsDto.EncodeMediaAs + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EncodeMediaAs + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value) - { - setting.Value = (updateSettingsDto.HostName + string.Empty).Trim(); - setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) - { - // Validate new directory can be used - if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) - { - return BadRequest( - await _localizationService.Translate(User.GetUserId(), "bookmark-dir-permissions")); - } - - originalBookmarkDirectory = setting.Value; - // Normalize the path deliminators. Just to look nice in DB, no functionality - setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); - _unitOfWork.SettingsRepository.Update(setting); - updateBookmarks = true; - - } - - if (setting.Key == ServerSettingKey.AllowStatCollection && - updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TotalBackups && - updateSettingsDto.TotalBackups + string.Empty != setting.Value) - { - if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-backups")); - } - - setting.Value = updateSettingsDto.TotalBackups + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TotalLogs && - updateSettingsDto.TotalLogs + string.Empty != setting.Value) - { - if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-logs")); - } - - setting.Value = updateSettingsDto.TotalLogs + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EnableFolderWatching && - updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - } - - if (!_unitOfWork.HasChanges()) return Ok(updateSettingsDto); - try { - await _unitOfWork.CommitAsync(); - - if (!updateSettingsDto.AllowStatCollection) - { - _taskScheduler.CancelStatsTasks(); - } - else - { - await _taskScheduler.ScheduleStatsTasks(); - } - - if (updateBookmarks) - { - UpdateBookmarkDirectory(originalBookmarkDirectory, bookmarkDirectory); - } - - if (updateSettingsDto.EnableFolderWatching) - { - BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); - } - else - { - BackgroundJob.Enqueue(() => _libraryWatcher.StopWatching()); - } + var d = await _settingsService.UpdateSettings(updateSettingsDto); + return Ok(d); + } + catch (KavitaException ex) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } catch (Exception ex) { _logger.LogError(ex, "There was an exception when updating server settings"); - await _unitOfWork.RollbackAsync(); return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } - - - _logger.LogInformation("Server Settings updated"); - BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); - - return Ok(updateSettingsDto); } - - private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) - { - _directoryService.ExistOrCreate(bookmarkDirectory); - _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); - _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); - } - - private void UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) - { - if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) - { - setting.Value = updateSettingsDto.TaskBackup; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) - { - setting.Value = updateSettingsDto.TaskScan; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value) - { - setting.Value = updateSettingsDto.TaskCleanup; - _unitOfWork.SettingsRepository.Update(setting); - } - } - - private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) - { - if (setting.Key == ServerSettingKey.EmailHost && - updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailPort && - updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailAuthPassword && - updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailAuthUserName && - updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSenderAddress && - updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSenderDisplayName && - updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSizeLimit && - updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailEnableSsl && - updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailCustomizedTemplates && - updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - } - - /// /// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup. /// @@ -523,4 +221,36 @@ public class SettingsController : BaseApiController if (string.IsNullOrEmpty(user?.Email)) return BadRequest("Your account has no email on record. Cannot email."); return Ok(await _emailService.SendTestEmail(user!.Email)); } + + /// + /// Get the metadata settings for Kavita+ users. + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("metadata-settings")] + public async Task> GetMetadataSettings() + { + return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto()); + + } + + /// + /// Update the metadata settings for Kavita+ Metadata feature + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("metadata-settings")] + public async Task> UpdateMetadataSettings(MetadataSettingsDto dto) + { + try + { + return Ok(await _settingsService.UpdateMetadataSettings(dto)); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue when updating metadata settings"); + return BadRequest(ex.Message); + } + } } diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index 87080312a..383905edd 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -222,18 +222,4 @@ public class StatsController : BaseApiController return Ok(_statService.GetWordsReadCountByYear(userId)); } - /// - /// Returns for Kavita+ the number of Series that have been processed, errored, and not processed - /// - /// - [Authorize("RequireAdminRole")] - [HttpGet("kavitaplus-metadata-breakdown")] - [ResponseCache(CacheProfileName = "Statistics")] - public async Task>>> GetKavitaPlusMetadataBreakdown() - { - if (!await _licenseService.HasActiveLicense()) - return BadRequest("This data is not available for non-Kavita+ servers"); - return Ok(await _statService.GetKavitaPlusMetadataBreakdown()); - } - } diff --git a/API/Controllers/StreamController.cs b/API/Controllers/StreamController.cs index a694d5b34..049885e78 100644 --- a/API/Controllers/StreamController.cs +++ b/API/Controllers/StreamController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs.Dashboard; using API.DTOs.SideNav; @@ -19,11 +20,13 @@ public class StreamController : BaseApiController { private readonly IStreamService _streamService; private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; - public StreamController(IStreamService streamService, IUnitOfWork unitOfWork) + public StreamController(IStreamService streamService, IUnitOfWork unitOfWork, ILocalizationService localizationService) { _streamService = streamService; _unitOfWork = unitOfWork; + _localizationService = localizationService; } /// @@ -74,6 +77,7 @@ public class StreamController : BaseApiController [HttpPost("update-external-source")] public async Task> UpdateExternalSource(ExternalSourceDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); // Check if a host and api key exists for the current user return Ok(await _streamService.UpdateExternalSource(User.GetUserId(), dto)); } @@ -86,7 +90,8 @@ public class StreamController : BaseApiController [HttpGet("external-source-exists")] public async Task> ExternalSourceExists(string host, string name, string apiKey) { - return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), host, name, apiKey)); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), name, host, apiKey)); } /// @@ -97,6 +102,7 @@ public class StreamController : BaseApiController [HttpDelete("delete-external-source")] public async Task ExternalSourceExists(int externalSourceId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.DeleteExternalSource(User.GetUserId(), externalSourceId); return Ok(); } @@ -110,6 +116,7 @@ public class StreamController : BaseApiController [HttpPost("add-dashboard-stream")] public async Task> AddDashboard([FromQuery] int smartFilterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateDashboardStreamFromSmartFilter(User.GetUserId(), smartFilterId)); } @@ -121,6 +128,7 @@ public class StreamController : BaseApiController [HttpPost("update-dashboard-stream")] public async Task UpdateDashboardStream(DashboardStreamDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateDashboardStream(User.GetUserId(), dto); return Ok(); } @@ -133,6 +141,7 @@ public class StreamController : BaseApiController [HttpPost("update-dashboard-position")] public async Task UpdateDashboardStreamPosition(UpdateStreamPositionDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateDashboardStreamPosition(User.GetUserId(), dto); return Ok(); } @@ -146,6 +155,7 @@ public class StreamController : BaseApiController [HttpPost("add-sidenav-stream")] public async Task> AddSideNav([FromQuery] int smartFilterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateSideNavStreamFromSmartFilter(User.GetUserId(), smartFilterId)); } @@ -157,6 +167,7 @@ public class StreamController : BaseApiController [HttpPost("add-sidenav-stream-from-external-source")] public async Task> AddSideNavFromExternalSource([FromQuery] int externalSourceId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateSideNavStreamFromExternalSource(User.GetUserId(), externalSourceId)); } @@ -168,6 +179,7 @@ public class StreamController : BaseApiController [HttpPost("update-sidenav-stream")] public async Task UpdateSideNavStream(SideNavStreamDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStream(User.GetUserId(), dto); return Ok(); } @@ -180,6 +192,7 @@ public class StreamController : BaseApiController [HttpPost("update-sidenav-position")] public async Task UpdateSideNavStreamPosition(UpdateStreamPositionDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto); return Ok(); } @@ -187,7 +200,34 @@ public class StreamController : BaseApiController [HttpPost("bulk-sidenav-stream-visibility")] public async Task BulkUpdateSideNavStream(BulkUpdateSideNavStreamVisibilityDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStreamBulk(User.GetUserId(), dto); return Ok(); } + + /// + /// Removes a Smart Filter from a user's SideNav Streams + /// + /// + /// + [HttpDelete("smart-filter-side-nav-stream")] + public async Task DeleteSmartFilterSideNavStream([FromQuery] int sideNavStreamId) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + await _streamService.DeleteSideNavSmartFilterStream(User.GetUserId(), sideNavStreamId); + return Ok(); + } + + /// + /// Removes a Smart Filter from a user's Dashboard Streams + /// + /// + /// + [HttpDelete("smart-filter-dashboard-stream")] + public async Task DeleteSmartFilterDashboardStream([FromQuery] int dashboardStreamId) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + await _streamService.DeleteDashboardSmartFilterStream(User.GetUserId(), dashboardStreamId); + return Ok(); + } } diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs index fb9371919..9e4cee20c 100644 --- a/API/Controllers/ThemeController.cs +++ b/API/Controllers/ThemeController.cs @@ -103,7 +103,7 @@ public class ThemeController : BaseApiController [HttpDelete] public async Task>> DeleteTheme(int themeId) { - + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _themeService.DeleteTheme(themeId); return Ok(); @@ -128,6 +128,8 @@ public class ThemeController : BaseApiController [HttpPost("upload-theme")] public async Task> DownloadTheme(IFormFile formFile) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (!formFile.FileName.EndsWith(".css")) return BadRequest("Invalid file"); if (formFile.FileName.Contains("..")) return BadRequest("Invalid file"); var tempFile = await UploadToTemp(formFile); diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 333d4b45f..9652ba494 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -6,8 +6,10 @@ using API.Data; using API.Data.Repositories; using API.DTOs.Uploads; using API.Entities.Enums; +using API.Entities.MetadataMatching; using API.Extensions; using API.Services; +using API.Services.Tasks.Metadata; using API.SignalR; using Flurl.Http; using Microsoft.AspNetCore.Authorization; @@ -31,11 +33,12 @@ public class UploadController : BaseApiController private readonly IEventHub _eventHub; private readonly IReadingListService _readingListService; private readonly ILocalizationService _localizationService; + private readonly ICoverDbService _coverDbService; /// public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService, - ILocalizationService localizationService) + ILocalizationService localizationService, ICoverDbService coverDbService) { _unitOfWork = unitOfWork; _imageService = imageService; @@ -45,6 +48,7 @@ public class UploadController : BaseApiController _eventHub = eventHub; _readingListService = readingListService; _localizationService = localizationService; + _coverDbService = coverDbService; } /// @@ -107,13 +111,12 @@ public class UploadController : BaseApiController lockState = uploadFileDto.LockCover; } - if (!string.IsNullOrEmpty(filePath)) - { - series.CoverImage = filePath; - series.CoverImageLocked = lockState; - _imageService.UpdateColorScape(series); - _unitOfWork.SeriesRepository.Update(series); - } + series.CoverImage = filePath; + series.CoverImageLocked = lockState; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Covers); + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + _unitOfWork.SeriesRepository.Update(series.Metadata); if (_unitOfWork.HasChanges()) { @@ -277,6 +280,7 @@ public class UploadController : BaseApiController chapter.CoverImage = filePath; chapter.CoverImageLocked = lockState; + chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterCovers); _unitOfWork.ChapterRepository.Update(chapter); var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); if (volume != null) @@ -490,35 +494,13 @@ public class UploadController : BaseApiController [HttpPost("person")] public async Task UploadPersonCoverImageFromUrl(UploadFileDto uploadFileDto) { - // Check if Url is non-empty, request the image and place in temp, then ask image service to handle it. - // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); - } - try { var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id); if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); - var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetPersonFormat(uploadFileDto.Id)}"); - - if (!string.IsNullOrEmpty(filePath)) - { - person.CoverImage = filePath; - person.CoverImageLocked = true; - _imageService.UpdateColorScape(person); - _unitOfWork.PersonRepository.Update(person); - } - - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false); - return Ok(); - } + await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, true); + return Ok(); } catch (Exception e) { diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 26039d700..17ebc758e 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -1,11 +1,14 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.KavitaPlus.Account; using API.Extensions; using API.Services; +using API.Services.Plus; using API.SignalR; using AutoMapper; using Microsoft.AspNetCore.Authorization; @@ -22,14 +25,16 @@ public class UsersController : BaseApiController private readonly IMapper _mapper; private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; + private readonly ILicenseService _licenseService; public UsersController(IUnitOfWork unitOfWork, IMapper mapper, IEventHub eventHub, - ILocalizationService localizationService) + ILocalizationService localizationService, ILicenseService licenseService) { _unitOfWork = unitOfWork; _mapper = mapper; _eventHub = eventHub; _localizationService = localizationService; + _licenseService = licenseService; } [Authorize(Policy = "RequireAdminRole")] @@ -82,45 +87,36 @@ public class UsersController : BaseApiController return Ok(libs.Any(x => x.Id == libraryId)); } + /// + /// Update the user preferences + /// + /// If the user has ReadOnly role, they will not be able to perform this action + /// + /// [HttpPost("update-preferences")] public async Task> UpdatePreferences(UserPreferencesDto preferencesDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.UserPreferences); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var existingPreferences = user!.UserPreferences; - existingPreferences.ReadingDirection = preferencesDto.ReadingDirection; - existingPreferences.ScalingOption = preferencesDto.ScalingOption; - existingPreferences.PageSplitOption = preferencesDto.PageSplitOption; - existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu; - existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints; - existingPreferences.EmulateBook = preferencesDto.EmulateBook; - existingPreferences.ReaderMode = preferencesDto.ReaderMode; - existingPreferences.LayoutMode = preferencesDto.LayoutMode; - existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor; - existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin; - existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing; - existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily; - existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; - existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; - existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; - existingPreferences.BookReaderWritingStyle = preferencesDto.BookReaderWritingStyle; - existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName; - existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode; - existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode; existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode; existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries; - existingPreferences.LayoutMode = preferencesDto.LayoutMode; existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; existingPreferences.NoTransitions = preferencesDto.NoTransitions; - existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ShareReviews = preferencesDto.ShareReviews; - existingPreferences.PdfTheme = preferencesDto.PdfTheme; - existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode; - existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode; + if (await _licenseService.HasActiveLicense()) + { + existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled; + existingPreferences.WantToReadSync = preferencesDto.WantToReadSync; + } + + if (preferencesDto.Theme != null && existingPreferences.Theme.Id != preferencesDto.Theme?.Id) { @@ -129,11 +125,12 @@ public class UsersController : BaseApiController } - if (_localizationService.GetLocales().Contains(preferencesDto.Locale)) + if (_localizationService.GetLocales().Select(l => l.FileName).Contains(preferencesDto.Locale)) { existingPreferences.Locale = preferencesDto.Locale; } + _unitOfWork.UserRepository.Update(existingPreferences); if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-pref")); @@ -164,4 +161,18 @@ public class UsersController : BaseApiController { return Ok((await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(u => u.UserName)); } + + /// + /// Returns all users with tokens registered and their token information. Does not send the tokens. + /// + /// Kavita+ only + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("tokens")] + public async Task>> GetUserTokens() + { + if (!await _licenseService.HasActiveLicense()) return BadRequest(_localizationService.Translate(User.GetUserId(), "kavitaplus-restricted")); + + return Ok((await _unitOfWork.UserRepository.GetUserTokenInfo())); + } } diff --git a/API/Controllers/VolumeController.cs b/API/Controllers/VolumeController.cs index 5d23336b4..db1381d9d 100644 --- a/API/Controllers/VolumeController.cs +++ b/API/Controllers/VolumeController.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; @@ -9,6 +11,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; +#nullable enable public class VolumeController : BaseApiController { @@ -23,13 +26,15 @@ public class VolumeController : BaseApiController _eventHub = eventHub; } + /// + /// Returns the appropriate Volume + /// + /// + /// [HttpGet] - public async Task> GetVolume(int volumeId) + public async Task> GetVolume(int volumeId) { - var volume = - await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId()); - - return Ok(volume); + return Ok(await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId())); } [Authorize(Policy = "RequireAdminRole")] @@ -39,7 +44,7 @@ public class VolumeController : BaseApiController var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters | VolumeIncludes.People | VolumeIncludes.Tags); if (volume == null) - return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); + return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); _unitOfWork.VolumeRepository.Remove(volume); @@ -51,4 +56,29 @@ public class VolumeController : BaseApiController return Ok(false); } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("multiple")] + public async Task> DeleteMultipleVolumes(int[] volumesIds) + { + var volumes = await _unitOfWork.VolumeRepository.GetVolumesById(volumesIds); + if (volumes.Count != volumesIds.Length) + { + return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); + } + + _unitOfWork.VolumeRepository.Remove(volumes); + + if (!await _unitOfWork.CommitAsync()) + { + return Ok(false); + } + + foreach (var volume in volumes) + { + await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volume.Id, volume.SeriesId), false); + } + + return Ok(true); + } } diff --git a/API/DTOs/Account/AgeRestrictionDto.cs b/API/DTOs/Account/AgeRestrictionDto.cs index 0aaec9b97..6505bdbff 100644 --- a/API/DTOs/Account/AgeRestrictionDto.cs +++ b/API/DTOs/Account/AgeRestrictionDto.cs @@ -2,15 +2,15 @@ namespace API.DTOs.Account; -public class AgeRestrictionDto +public sealed record AgeRestrictionDto { /// /// The maximum age rating a user has access to. -1 if not applicable /// - public required AgeRating AgeRating { get; set; } = AgeRating.NotApplicable; + public required AgeRating AgeRating { get; init; } = AgeRating.NotApplicable; /// /// Are Unknowns explicitly allowed against age rating /// /// Unknown is always lowest and default age rating. Setting this to false will ensure Teen age rating applies and unknowns are still filtered - public required bool IncludeUnknowns { get; set; } = false; + public required bool IncludeUnknowns { get; init; } = false; } diff --git a/API/DTOs/Account/AniListUpdateDto.cs b/API/DTOs/Account/AniListUpdateDto.cs deleted file mode 100644 index d51a1dc0d..000000000 --- a/API/DTOs/Account/AniListUpdateDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace API.DTOs.Account; - -public class AniListUpdateDto -{ - public string Token { get; set; } -} diff --git a/API/DTOs/Account/ConfirmEmailDto.cs b/API/DTOs/Account/ConfirmEmailDto.cs index 2f5849e74..413f9f34a 100644 --- a/API/DTOs/Account/ConfirmEmailDto.cs +++ b/API/DTOs/Account/ConfirmEmailDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ConfirmEmailDto +public sealed record ConfirmEmailDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/ConfirmEmailUpdateDto.cs b/API/DTOs/Account/ConfirmEmailUpdateDto.cs index 42abb1295..2a0738e35 100644 --- a/API/DTOs/Account/ConfirmEmailUpdateDto.cs +++ b/API/DTOs/Account/ConfirmEmailUpdateDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ConfirmEmailUpdateDto +public sealed record ConfirmEmailUpdateDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/ConfirmMigrationEmailDto.cs b/API/DTOs/Account/ConfirmMigrationEmailDto.cs index efb42b8fd..cdfc1505c 100644 --- a/API/DTOs/Account/ConfirmMigrationEmailDto.cs +++ b/API/DTOs/Account/ConfirmMigrationEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class ConfirmMigrationEmailDto +public sealed record ConfirmMigrationEmailDto { public string Email { get; set; } = default!; public string Token { get; set; } = default!; diff --git a/API/DTOs/Account/ConfirmPasswordResetDto.cs b/API/DTOs/Account/ConfirmPasswordResetDto.cs index 16dd86f9a..00aff301b 100644 --- a/API/DTOs/Account/ConfirmPasswordResetDto.cs +++ b/API/DTOs/Account/ConfirmPasswordResetDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ConfirmPasswordResetDto +public sealed record ConfirmPasswordResetDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs index 112013053..c12bebc2b 100644 --- a/API/DTOs/Account/InviteUserDto.cs +++ b/API/DTOs/Account/InviteUserDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.Account; -public class InviteUserDto +public sealed record InviteUserDto { [Required] public string Email { get; set; } = default!; diff --git a/API/DTOs/Account/InviteUserResponse.cs b/API/DTOs/Account/InviteUserResponse.cs index a7e0d86ea..ed16bd05e 100644 --- a/API/DTOs/Account/InviteUserResponse.cs +++ b/API/DTOs/Account/InviteUserResponse.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class InviteUserResponse +public sealed record InviteUserResponse { /// /// Email link used to setup the user account diff --git a/API/DTOs/Account/LoginDto.cs b/API/DTOs/Account/LoginDto.cs index fe8fce088..97338640b 100644 --- a/API/DTOs/Account/LoginDto.cs +++ b/API/DTOs/Account/LoginDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs.Account; #nullable enable -public class LoginDto +public sealed record LoginDto { public string Username { get; init; } = default!; public string Password { get; set; } = default!; diff --git a/API/DTOs/Account/MigrateUserEmailDto.cs b/API/DTOs/Account/MigrateUserEmailDto.cs index 60d042165..4630c510f 100644 --- a/API/DTOs/Account/MigrateUserEmailDto.cs +++ b/API/DTOs/Account/MigrateUserEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class MigrateUserEmailDto +public sealed record MigrateUserEmailDto { public string Email { get; set; } = default!; public string Username { get; set; } = default!; diff --git a/API/DTOs/Account/ResetPasswordDto.cs b/API/DTOs/Account/ResetPasswordDto.cs index 51a195131..545ca5ba6 100644 --- a/API/DTOs/Account/ResetPasswordDto.cs +++ b/API/DTOs/Account/ResetPasswordDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Account; -public class ResetPasswordDto +public sealed record ResetPasswordDto { /// /// The Username of the User diff --git a/API/DTOs/Account/TokenRequestDto.cs b/API/DTOs/Account/TokenRequestDto.cs index 85ab9f87a..5c798721c 100644 --- a/API/DTOs/Account/TokenRequestDto.cs +++ b/API/DTOs/Account/TokenRequestDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class TokenRequestDto +public sealed record TokenRequestDto { public string Token { get; init; } = default!; public string RefreshToken { get; init; } = default!; diff --git a/API/DTOs/Account/UpdateAgeRestrictionDto.cs b/API/DTOs/Account/UpdateAgeRestrictionDto.cs index ef6be1bba..2fa9c89d2 100644 --- a/API/DTOs/Account/UpdateAgeRestrictionDto.cs +++ b/API/DTOs/Account/UpdateAgeRestrictionDto.cs @@ -3,7 +3,7 @@ using API.Entities.Enums; namespace API.DTOs.Account; -public class UpdateAgeRestrictionDto +public sealed record UpdateAgeRestrictionDto { [Required] public AgeRating AgeRating { get; set; } diff --git a/API/DTOs/Account/UpdateEmailDto.cs b/API/DTOs/Account/UpdateEmailDto.cs index eac06be53..873862ba1 100644 --- a/API/DTOs/Account/UpdateEmailDto.cs +++ b/API/DTOs/Account/UpdateEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Account; -public class UpdateEmailDto +public sealed record UpdateEmailDto { public string Email { get; set; } = default!; public string Password { get; set; } = default!; diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs index bda664bdb..0cb0eaf66 100644 --- a/API/DTOs/Account/UpdateUserDto.cs +++ b/API/DTOs/Account/UpdateUserDto.cs @@ -2,13 +2,18 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.Account; +#nullable enable -public record UpdateUserDto +public sealed record UpdateUserDto { + /// public int UserId { get; set; } + /// public string Username { get; set; } = default!; + /// /// List of Roles to assign to user. If admin not present, Pleb will be applied. /// If admin present, all libraries will be granted access and will ignore those from DTO. + /// public IList Roles { get; init; } = default!; /// /// A list of libraries to grant access to @@ -18,4 +23,6 @@ public record UpdateUserDto /// An Age Rating which will limit the account to seeing everything equal to or below said rating. /// public AgeRestrictionDto AgeRestriction { get; init; } = default!; + /// + public string? Email { get; set; } = default!; } diff --git a/API/DTOs/BulkActionDto.cs b/API/DTOs/BulkActionDto.cs index d3ce75293..c26a73e9c 100644 --- a/API/DTOs/BulkActionDto.cs +++ b/API/DTOs/BulkActionDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class BulkActionDto +public sealed record BulkActionDto { public List Ids { get; set; } /** diff --git a/API/DTOs/ChapterDetailPlusDto.cs b/API/DTOs/ChapterDetailPlusDto.cs new file mode 100644 index 000000000..d99482e55 --- /dev/null +++ b/API/DTOs/ChapterDetailPlusDto.cs @@ -0,0 +1,14 @@ +#nullable enable +using System.Collections.Generic; +using API.DTOs.SeriesDetail; + +namespace API.DTOs; + +public sealed record ChapterDetailPlusDto +{ + public float Rating { get; set; } + public bool HasBeenRated { get; set; } + + public IList Reviews { get; set; } = []; + public IList Ratings { get; set; } = []; +} diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 634ced4e9..85624b51c 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; using API.Entities.Interfaces; namespace API.DTOs; +#nullable enable /// /// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying @@ -12,37 +14,24 @@ namespace API.DTOs; /// public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage { + /// public int Id { get; init; } - /// - /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". If special, will be special name. - /// - /// This can be something like 19.HU or Alpha as some comics are like this + /// public string Range { get; init; } = default!; - /// - /// Smallest number of the Range. - /// + /// [Obsolete("Use MinNumber and MaxNumber instead")] public string Number { get; init; } = default!; - /// - /// This may be 0 under the circumstance that the Issue is "Alpha" or other non-standard numbers. - /// + /// public float MinNumber { get; init; } + /// public float MaxNumber { get; init; } - /// - /// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden. - /// + /// public float SortOrder { get; set; } - /// - /// Total number of pages in all MangaFiles - /// + /// public int Pages { get; init; } - /// - /// If this Chapter contains files that could only be identified as Series or has Special Identifier from filename - /// + /// public bool IsSpecial { get; init; } - /// - /// Used for books/specials to display custom title. For non-specials/books, will be set to - /// + /// public string Title { get; set; } = default!; /// /// The files that represent this Chapter @@ -60,46 +49,25 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage /// The last time a chapter was read by current authenticated user /// public DateTime LastReadingProgress { get; set; } - /// - /// If the Cover Image is locked for this entity - /// + /// public bool CoverImageLocked { get; set; } - /// - /// Volume Id this Chapter belongs to - /// + /// public int VolumeId { get; init; } - /// - /// When chapter was created - /// + /// public DateTime CreatedUtc { get; set; } + /// public DateTime LastModifiedUtc { get; set; } - /// - /// When chapter was created in local server time - /// - /// This is required for Tachiyomi Extension + /// public DateTime Created { get; set; } - /// - /// When the chapter was released. - /// - /// Metadata field + /// public DateTime ReleaseDate { get; init; } - /// - /// Title of the Chapter/Issue - /// - /// Metadata field + /// public string TitleName { get; set; } = default!; - /// - /// Summary of the Chapter - /// - /// This is not set normally, only for Series Detail + /// public string Summary { get; init; } = default!; - /// - /// Age Rating for the issue/chapter - /// + /// public AgeRating AgeRating { get; init; } - /// - /// Total words in a Chapter (books only) - /// + /// public long WordCount { get; set; } = 0L; /// /// Formatted Volume title ie) Volume 2. @@ -112,14 +80,9 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage public int MaxHoursToRead { get; set; } /// public float AvgHoursToRead { get; set; } - /// - /// Comma-separated link of urls to external services that have some relation to the Chapter - /// + /// public string WebLinks { get; set; } - /// - /// ISBN-13 (usually) of the Chapter - /// - /// This is guaranteed to be Valid + /// public string ISBN { get; set; } #region Metadata @@ -145,51 +108,60 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage /// public ICollection Tags { get; set; } = new List(); public PublicationStatus PublicationStatus { get; set; } - /// - /// Language for the Chapter/Issue - /// + /// public string? Language { get; set; } - /// - /// Number in the TotalCount of issues - /// + /// public int Count { get; set; } - /// - /// Total number of issues for the series - /// + /// public int TotalCount { get; set; } + /// public bool LanguageLocked { get; set; } + /// public bool SummaryLocked { get; set; } - /// - /// Locked by user so metadata updates from scan loop will not override AgeRating - /// + /// public bool AgeRatingLocked { get; set; } - /// - /// Locked by user so metadata updates from scan loop will not override PublicationStatus - /// public bool PublicationStatusLocked { get; set; } + /// public bool GenresLocked { get; set; } + /// public bool TagsLocked { get; set; } + /// public bool WriterLocked { get; set; } + /// public bool CharacterLocked { get; set; } + /// public bool ColoristLocked { get; set; } + /// public bool EditorLocked { get; set; } + /// public bool InkerLocked { get; set; } + /// public bool ImprintLocked { get; set; } + /// public bool LettererLocked { get; set; } + /// public bool PencillerLocked { get; set; } + /// public bool PublisherLocked { get; set; } + /// public bool TranslatorLocked { get; set; } + /// public bool TeamLocked { get; set; } + /// public bool LocationLocked { get; set; } + /// public bool CoverArtistLocked { get; set; } public bool ReleaseYearLocked { get; set; } #endregion - public string CoverImage { get; set; } - public string PrimaryColor { get; set; } - public string SecondaryColor { get; set; } + /// + public string? CoverImage { get; set; } + /// + public string? PrimaryColor { get; set; } = string.Empty; + /// + public string? SecondaryColor { get; set; } = string.Empty; public void ResetColorScape() { diff --git a/API/DTOs/Collection/AppUserCollectionDto.cs b/API/DTOs/Collection/AppUserCollectionDto.cs index 4c7ba0f44..0634b5d83 100644 --- a/API/DTOs/Collection/AppUserCollectionDto.cs +++ b/API/DTOs/Collection/AppUserCollectionDto.cs @@ -6,48 +6,52 @@ using API.Services.Plus; namespace API.DTOs.Collection; #nullable enable -public class AppUserCollectionDto : IHasCoverImage +public sealed record AppUserCollectionDto : IHasCoverImage { public int Id { get; init; } - public string Title { get; set; } = default!; - public string Summary { get; set; } = default!; - public bool Promoted { get; set; } - public AgeRating AgeRating { get; set; } + public string Title { get; init; } = default!; + public string? Summary { get; init; } = default!; + public bool Promoted { get; init; } + public AgeRating AgeRating { get; init; } /// /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. /// public string? CoverImage { get; set; } = string.Empty; - public string PrimaryColor { get; set; } = string.Empty; - public string SecondaryColor { get; set; } = string.Empty; - public bool CoverImageLocked { get; set; } + public string? PrimaryColor { get; set; } = string.Empty; + public string? SecondaryColor { get; set; } = string.Empty; + public bool CoverImageLocked { get; init; } + + /// + /// Number of Series in the Collection + /// + public int ItemCount { get; init; } /// /// Owner of the Collection /// - public string? Owner { get; set; } - + public string? Owner { get; init; } /// /// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections) /// - public DateTime LastSyncUtc { get; set; } + public DateTime LastSyncUtc { get; init; } /// /// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote /// - public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita; + public ScrobbleProvider Source { get; init; } = ScrobbleProvider.Kavita; /// /// For Non-Kavita sourced collections, the url to sync from /// - public string? SourceUrl { get; set; } + public string? SourceUrl { get; init; } /// /// Total number of items as of the last sync. Not applicable for Kavita managed collections. /// - public int TotalSourceCount { get; set; } + public int TotalSourceCount { get; init; } /// /// A
separated string of all missing series ///
- public string? MissingSeriesFromSource { get; set; } + public string? MissingSeriesFromSource { get; init; } public void ResetColorScape() { diff --git a/API/DTOs/Collection/MalStackDto.cs b/API/DTOs/Collection/MalStackDto.cs index 3144f6c72..d9d902e88 100644 --- a/API/DTOs/Collection/MalStackDto.cs +++ b/API/DTOs/Collection/MalStackDto.cs @@ -1,4 +1,5 @@ namespace API.DTOs.Collection; +#nullable enable /// /// Represents an Interest Stack from MAL diff --git a/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs b/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs index 1d078959d..0a2270fbf 100644 --- a/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs +++ b/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.CollectionTags; -public class CollectionTagBulkAddDto +public sealed record CollectionTagBulkAddDto { /// /// Collection Tag Id diff --git a/API/DTOs/CollectionTags/CollectionTagDto.cs b/API/DTOs/CollectionTags/CollectionTagDto.cs index ec9939ebd..911622051 100644 --- a/API/DTOs/CollectionTags/CollectionTagDto.cs +++ b/API/DTOs/CollectionTags/CollectionTagDto.cs @@ -3,15 +3,21 @@ namespace API.DTOs.CollectionTags; [Obsolete("Use AppUserCollectionDto")] -public class CollectionTagDto +public sealed record CollectionTagDto { + /// public int Id { get; set; } + /// public string Title { get; set; } = default!; + /// public string Summary { get; set; } = default!; + /// public bool Promoted { get; set; } /// /// The cover image string. This is used on Frontend to show or hide the Cover Image /// + /// public string CoverImage { get; set; } = default!; + /// public bool CoverImageLocked { get; set; } } diff --git a/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs b/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs index 19e9a11e2..139834a60 100644 --- a/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs +++ b/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs @@ -4,7 +4,7 @@ using API.DTOs.Collection; namespace API.DTOs.CollectionTags; -public class UpdateSeriesForTagDto +public sealed record UpdateSeriesForTagDto { public AppUserCollectionDto Tag { get; init; } = default!; public IEnumerable SeriesIdsToRemove { get; init; } = default!; diff --git a/API/DTOs/ColorScape.cs b/API/DTOs/ColorScape.cs index 39d1446dd..5351f2351 100644 --- a/API/DTOs/ColorScape.cs +++ b/API/DTOs/ColorScape.cs @@ -1,9 +1,10 @@ namespace API.DTOs; +#nullable enable /// /// A primary and secondary color /// -public class ColorScape +public sealed record ColorScape { public required string? Primary { get; set; } public required string? Secondary { get; set; } diff --git a/API/DTOs/CopySettingsFromLibraryDto.cs b/API/DTOs/CopySettingsFromLibraryDto.cs index ee75f7422..5ca5ead51 100644 --- a/API/DTOs/CopySettingsFromLibraryDto.cs +++ b/API/DTOs/CopySettingsFromLibraryDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class CopySettingsFromLibraryDto +public sealed record CopySettingsFromLibraryDto { public int SourceLibraryId { get; set; } public List TargetLibraryIds { get; set; } diff --git a/API/DTOs/CoverDb/CoverDbAuthor.cs b/API/DTOs/CoverDb/CoverDbAuthor.cs new file mode 100644 index 000000000..ca924801f --- /dev/null +++ b/API/DTOs/CoverDb/CoverDbAuthor.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace API.DTOs.CoverDb; + +public sealed record CoverDbAuthor +{ + [YamlMember(Alias = "name", ApplyNamingConventions = false)] + public string Name { get; set; } + [YamlMember(Alias = "aliases", ApplyNamingConventions = false)] + public List Aliases { get; set; } = new List(); + [YamlMember(Alias = "ids", ApplyNamingConventions = false)] + public CoverDbPersonIds Ids { get; set; } + [YamlMember(Alias = "image_path", ApplyNamingConventions = false)] + public string ImagePath { get; set; } +} diff --git a/API/DTOs/CoverDb/CoverDbPeople.cs b/API/DTOs/CoverDb/CoverDbPeople.cs new file mode 100644 index 000000000..2e825eac7 --- /dev/null +++ b/API/DTOs/CoverDb/CoverDbPeople.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace API.DTOs.CoverDb; + +public sealed record CoverDbPeople +{ + [YamlMember(Alias = "people", ApplyNamingConventions = false)] + public List People { get; set; } = new List(); +} diff --git a/API/DTOs/CoverDb/CoverDbPersonIds.cs b/API/DTOs/CoverDb/CoverDbPersonIds.cs new file mode 100644 index 000000000..5816bb479 --- /dev/null +++ b/API/DTOs/CoverDb/CoverDbPersonIds.cs @@ -0,0 +1,20 @@ +using YamlDotNet.Serialization; + +namespace API.DTOs.CoverDb; +#nullable enable + +public sealed record CoverDbPersonIds +{ + [YamlMember(Alias = "hardcover_id", ApplyNamingConventions = false)] + public string? HardcoverId { get; set; } = null; + [YamlMember(Alias = "amazon_id", ApplyNamingConventions = false)] + public string? AmazonId { get; set; } = null; + [YamlMember(Alias = "metron_id", ApplyNamingConventions = false)] + public string? MetronId { get; set; } = null; + [YamlMember(Alias = "comicvine_id", ApplyNamingConventions = false)] + public string? ComicVineId { get; set; } = null; + [YamlMember(Alias = "anilist_id", ApplyNamingConventions = false)] + public string? AnilistId { get; set; } = null; + [YamlMember(Alias = "mal_id", ApplyNamingConventions = false)] + public string? MALId { get; set; } = null; +} diff --git a/API/DTOs/Dashboard/DashboardStreamDto.cs b/API/DTOs/Dashboard/DashboardStreamDto.cs index 59e5f4f7d..297a706b1 100644 --- a/API/DTOs/Dashboard/DashboardStreamDto.cs +++ b/API/DTOs/Dashboard/DashboardStreamDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.Dashboard; -public class DashboardStreamDto +public sealed record DashboardStreamDto { public int Id { get; set; } public required string Name { get; set; } diff --git a/API/DTOs/Dashboard/GroupedSeriesDto.cs b/API/DTOs/Dashboard/GroupedSeriesDto.cs index 3b283de34..940e42c40 100644 --- a/API/DTOs/Dashboard/GroupedSeriesDto.cs +++ b/API/DTOs/Dashboard/GroupedSeriesDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Dashboard; /// /// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section /// -public class GroupedSeriesDto +public sealed record GroupedSeriesDto { public string SeriesName { get; set; } = default!; public int SeriesId { get; set; } diff --git a/API/DTOs/Dashboard/RecentlyAddedItemDto.cs b/API/DTOs/Dashboard/RecentlyAddedItemDto.cs index 2e5658e2e..bb0360b30 100644 --- a/API/DTOs/Dashboard/RecentlyAddedItemDto.cs +++ b/API/DTOs/Dashboard/RecentlyAddedItemDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Dashboard; /// /// A mesh of data for Recently added volume/chapters /// -public class RecentlyAddedItemDto +public sealed record RecentlyAddedItemDto { public string SeriesName { get; set; } = default!; public int SeriesId { get; set; } diff --git a/API/DTOs/Dashboard/SmartFilterDto.cs b/API/DTOs/Dashboard/SmartFilterDto.cs index b23a74c69..c1bc4d7e1 100644 --- a/API/DTOs/Dashboard/SmartFilterDto.cs +++ b/API/DTOs/Dashboard/SmartFilterDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Dashboard; -public class SmartFilterDto +public sealed record SmartFilterDto { public int Id { get; set; } public required string Name { get; set; } diff --git a/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs b/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs index c2320f1a9..476a0732e 100644 --- a/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs +++ b/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Dashboard; -public class UpdateDashboardStreamPositionDto +public sealed record UpdateDashboardStreamPositionDto { public int FromPosition { get; set; } public int ToPosition { get; set; } diff --git a/API/DTOs/Dashboard/UpdateStreamPositionDto.cs b/API/DTOs/Dashboard/UpdateStreamPositionDto.cs index f9005a585..8de0ffa6f 100644 --- a/API/DTOs/Dashboard/UpdateStreamPositionDto.cs +++ b/API/DTOs/Dashboard/UpdateStreamPositionDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Dashboard; -public class UpdateStreamPositionDto +public sealed record UpdateStreamPositionDto { public int FromPosition { get; set; } public int ToPosition { get; set; } diff --git a/API/DTOs/DeleteChaptersDto.cs b/API/DTOs/DeleteChaptersDto.cs new file mode 100644 index 000000000..9fad2f1fb --- /dev/null +++ b/API/DTOs/DeleteChaptersDto.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace API.DTOs; + +public sealed record DeleteChaptersDto +{ + public IList ChapterIds { get; set; } = default!; +} diff --git a/API/DTOs/DeleteSeriesDto.cs b/API/DTOs/DeleteSeriesDto.cs index 12687fc25..ec9ba0c68 100644 --- a/API/DTOs/DeleteSeriesDto.cs +++ b/API/DTOs/DeleteSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class DeleteSeriesDto +public sealed record DeleteSeriesDto { public IList SeriesIds { get; set; } = default!; } diff --git a/API/DTOs/Device/CreateDeviceDto.cs b/API/DTOs/Device/CreateDeviceDto.cs index 7e59483fa..a8fdb6bc9 100644 --- a/API/DTOs/Device/CreateDeviceDto.cs +++ b/API/DTOs/Device/CreateDeviceDto.cs @@ -3,7 +3,7 @@ using API.Entities.Enums.Device; namespace API.DTOs.Device; -public class CreateDeviceDto +public sealed record CreateDeviceDto { [Required] public string Name { get; set; } = default!; diff --git a/API/DTOs/Device/DeviceDto.cs b/API/DTOs/Device/DeviceDto.cs index b2e83e6fc..42140dcc1 100644 --- a/API/DTOs/Device/DeviceDto.cs +++ b/API/DTOs/Device/DeviceDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Device; /// /// A Device is an entity that can receive data from Kavita (kindle) /// -public class DeviceDto +public sealed record DeviceDto { /// /// The device Id diff --git a/API/DTOs/Device/SendSeriesToDeviceDto.cs b/API/DTOs/Device/SendSeriesToDeviceDto.cs index a0a907464..58ce2293b 100644 --- a/API/DTOs/Device/SendSeriesToDeviceDto.cs +++ b/API/DTOs/Device/SendSeriesToDeviceDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Device; -public class SendSeriesToDeviceDto +public sealed record SendSeriesToDeviceDto { public int DeviceId { get; set; } public int SeriesId { get; set; } diff --git a/API/DTOs/Device/SendToDeviceDto.cs b/API/DTOs/Device/SendToDeviceDto.cs index fd88eaf59..a7a4dc0ff 100644 --- a/API/DTOs/Device/SendToDeviceDto.cs +++ b/API/DTOs/Device/SendToDeviceDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Device; -public class SendToDeviceDto +public sealed record SendToDeviceDto { public int DeviceId { get; set; } public IReadOnlyList ChapterIds { get; set; } = default!; diff --git a/API/DTOs/Device/UpdateDeviceDto.cs b/API/DTOs/Device/UpdateDeviceDto.cs index d28d372c3..2c3e72ea1 100644 --- a/API/DTOs/Device/UpdateDeviceDto.cs +++ b/API/DTOs/Device/UpdateDeviceDto.cs @@ -3,7 +3,7 @@ using API.Entities.Enums.Device; namespace API.DTOs.Device; -public class UpdateDeviceDto +public sealed record UpdateDeviceDto { [Required] public int Id { get; set; } diff --git a/API/DTOs/Downloads/DownloadBookmarkDto.cs b/API/DTOs/Downloads/DownloadBookmarkDto.cs index 5b7240b68..00f763dac 100644 --- a/API/DTOs/Downloads/DownloadBookmarkDto.cs +++ b/API/DTOs/Downloads/DownloadBookmarkDto.cs @@ -4,7 +4,7 @@ using API.DTOs.Reader; namespace API.DTOs.Downloads; -public class DownloadBookmarkDto +public sealed record DownloadBookmarkDto { [Required] public IEnumerable Bookmarks { get; set; } = default!; diff --git a/API/DTOs/Email/ConfirmationEmailDto.cs b/API/DTOs/Email/ConfirmationEmailDto.cs index 1a48c9974..197395794 100644 --- a/API/DTOs/Email/ConfirmationEmailDto.cs +++ b/API/DTOs/Email/ConfirmationEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class ConfirmationEmailDto +public sealed record ConfirmationEmailDto { public string InvitingUser { get; init; } = default!; public string EmailAddress { get; init; } = default!; diff --git a/API/DTOs/Email/EmailHistoryDto.cs b/API/DTOs/Email/EmailHistoryDto.cs new file mode 100644 index 000000000..c2968d091 --- /dev/null +++ b/API/DTOs/Email/EmailHistoryDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace API.DTOs.Email; + +public sealed record EmailHistoryDto +{ + public long Id { get; set; } + public bool Sent { get; set; } + public DateTime SendDate { get; set; } = DateTime.UtcNow; + public string EmailTemplate { get; set; } + public string ErrorMessage { get; set; } + public string ToUserName { get; set; } + +} diff --git a/API/DTOs/Email/EmailMigrationDto.cs b/API/DTOs/Email/EmailMigrationDto.cs index f051e7337..5354afdaa 100644 --- a/API/DTOs/Email/EmailMigrationDto.cs +++ b/API/DTOs/Email/EmailMigrationDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class EmailMigrationDto +public sealed record EmailMigrationDto { public string EmailAddress { get; init; } = default!; public string Username { get; init; } = default!; diff --git a/API/DTOs/Email/EmailTestResultDto.cs b/API/DTOs/Email/EmailTestResultDto.cs index 263e725c4..9be868eab 100644 --- a/API/DTOs/Email/EmailTestResultDto.cs +++ b/API/DTOs/Email/EmailTestResultDto.cs @@ -3,7 +3,7 @@ /// /// Represents if Test Email Service URL was successful or not and if any error occured /// -public class EmailTestResultDto +public sealed record EmailTestResultDto { public bool Successful { get; set; } public string ErrorMessage { get; set; } = default!; diff --git a/API/DTOs/Email/PasswordResetEmailDto.cs b/API/DTOs/Email/PasswordResetEmailDto.cs index 06abba171..9fda066a9 100644 --- a/API/DTOs/Email/PasswordResetEmailDto.cs +++ b/API/DTOs/Email/PasswordResetEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class PasswordResetEmailDto +public sealed record PasswordResetEmailDto { public string EmailAddress { get; init; } = default!; public string ServerConfirmationLink { get; init; } = default!; diff --git a/API/DTOs/Email/SendToDto.cs b/API/DTOs/Email/SendToDto.cs index 1261d110c..eacd29449 100644 --- a/API/DTOs/Email/SendToDto.cs +++ b/API/DTOs/Email/SendToDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Email; -public class SendToDto +public sealed record SendToDto { public string DestinationEmail { get; set; } = default!; public IEnumerable FilePaths { get; set; } = default!; diff --git a/API/DTOs/Email/TestEmailDto.cs b/API/DTOs/Email/TestEmailDto.cs index 37c12ed30..44c11bd6c 100644 --- a/API/DTOs/Email/TestEmailDto.cs +++ b/API/DTOs/Email/TestEmailDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Email; -public class TestEmailDto +public sealed record TestEmailDto { public string Url { get; set; } = default!; } diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index 9205a7bba..cb3374838 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -5,7 +5,7 @@ using API.Entities.Enums; namespace API.DTOs.Filtering; #nullable enable -public class FilterDto +public sealed record FilterDto { /// /// The type of Formats you want to be returned. An empty list will return all formats back diff --git a/API/DTOs/Filtering/LanguageDto.cs b/API/DTOs/Filtering/LanguageDto.cs index bc7ebb5cc..dde85f07e 100644 --- a/API/DTOs/Filtering/LanguageDto.cs +++ b/API/DTOs/Filtering/LanguageDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Filtering; -public class LanguageDto +public sealed record LanguageDto { public required string IsoCode { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Filtering/PersonSortField.cs b/API/DTOs/Filtering/PersonSortField.cs new file mode 100644 index 000000000..5268a1bf9 --- /dev/null +++ b/API/DTOs/Filtering/PersonSortField.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.Filtering; + +public enum PersonSortField +{ + Name = 1, + SeriesCount = 2, + ChapterCount = 3 +} diff --git a/API/DTOs/Filtering/Range.cs b/API/DTOs/Filtering/Range.cs index a75164fa3..e697f26e1 100644 --- a/API/DTOs/Filtering/Range.cs +++ b/API/DTOs/Filtering/Range.cs @@ -4,7 +4,7 @@ /// /// Represents a range between two int/float/double /// -public class Range +public sealed record Range { public T? Min { get; init; } public T? Max { get; init; } diff --git a/API/DTOs/Filtering/ReadStatus.cs b/API/DTOs/Filtering/ReadStatus.cs index eeb786714..81498ecb5 100644 --- a/API/DTOs/Filtering/ReadStatus.cs +++ b/API/DTOs/Filtering/ReadStatus.cs @@ -3,7 +3,7 @@ /// /// Represents the Reading Status. This is a flag and allows multiple statues /// -public class ReadStatus +public sealed record ReadStatus { public bool NotRead { get; set; } = true; public bool InProgress { get; set; } = true; diff --git a/API/DTOs/Filtering/SortOptions.cs b/API/DTOs/Filtering/SortOptions.cs index 00bf91675..18f2b17ea 100644 --- a/API/DTOs/Filtering/SortOptions.cs +++ b/API/DTOs/Filtering/SortOptions.cs @@ -3,8 +3,17 @@ /// /// Sorting Options for a query /// -public class SortOptions +public sealed record SortOptions { public SortField SortField { get; set; } public bool IsAscending { get; set; } = true; } + +/// +/// All Sorting Options for a query related to Person Entity +/// +public sealed record PersonSortOptions +{ + public PersonSortField SortField { get; set; } + public bool IsAscending { get; set; } = true; +} diff --git a/API/DTOs/Filtering/v2/DecodeFilterDto.cs b/API/DTOs/Filtering/v2/DecodeFilterDto.cs index 18dc166e7..db4c7ecce 100644 --- a/API/DTOs/Filtering/v2/DecodeFilterDto.cs +++ b/API/DTOs/Filtering/v2/DecodeFilterDto.cs @@ -3,7 +3,7 @@ /// /// For requesting an encoded filter to be decoded /// -public class DecodeFilterDto +public sealed record DecodeFilterDto { public string EncodedFilter { get; set; } } diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index 2159e4e11..246a92a90 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -51,6 +51,17 @@ public enum FilterField AverageRating = 28, Imprint = 29, Team = 30, - Location = 31 - + Location = 31, + /// + /// Last time User Read + /// + ReadLast = 32, +} + +public enum PersonFilterField +{ + Role = 1, + Name = 2, + SeriesCount = 3, + ChapterCount = 4, } diff --git a/API/DTOs/Filtering/v2/FilterStatementDto.cs b/API/DTOs/Filtering/v2/FilterStatementDto.cs index a6192093e..8c99bd24c 100644 --- a/API/DTOs/Filtering/v2/FilterStatementDto.cs +++ b/API/DTOs/Filtering/v2/FilterStatementDto.cs @@ -1,8 +1,17 @@ -namespace API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse.Requests; -public class FilterStatementDto +namespace API.DTOs.Filtering.v2; + +public sealed record FilterStatementDto { public FilterComparison Comparison { get; set; } public FilterField Field { get; set; } public string Value { get; set; } } + +public sealed record PersonFilterStatementDto +{ + public FilterComparison Comparison { get; set; } + public PersonFilterField Field { get; set; } + public string Value { get; set; } +} diff --git a/API/DTOs/Filtering/v2/FilterV2Dto.cs b/API/DTOs/Filtering/v2/FilterV2Dto.cs index 5bc50ff2f..a247a17a6 100644 --- a/API/DTOs/Filtering/v2/FilterV2Dto.cs +++ b/API/DTOs/Filtering/v2/FilterV2Dto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Filtering.v2; /// /// Metadata filtering for v2 API only /// -public class FilterV2Dto +public sealed record FilterV2Dto { /// /// Not used in the UI. @@ -16,7 +16,7 @@ public class FilterV2Dto /// The name of the filter /// public string? Name { get; set; } - public ICollection Statements { get; set; } = new List(); + public ICollection Statements { get; set; } = []; public FilterCombination Combination { get; set; } = FilterCombination.And; public SortOptions? SortOptions { get; set; } diff --git a/API/DTOs/Jobs/JobDto.cs b/API/DTOs/Jobs/JobDto.cs index 648765a34..55419811f 100644 --- a/API/DTOs/Jobs/JobDto.cs +++ b/API/DTOs/Jobs/JobDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Jobs; -public class JobDto +public sealed record JobDto { /// /// Job Id diff --git a/API/DTOs/JumpBar/JumpKeyDto.cs b/API/DTOs/JumpBar/JumpKeyDto.cs index 5a98a85ca..8dc5b4a8e 100644 --- a/API/DTOs/JumpBar/JumpKeyDto.cs +++ b/API/DTOs/JumpBar/JumpKeyDto.cs @@ -3,7 +3,7 @@ /// /// Represents an individual button in a Jump Bar /// -public class JumpKeyDto +public sealed record JumpKeyDto { /// /// Number of items in this Key diff --git a/API/DTOs/KavitaLocale.cs b/API/DTOs/KavitaLocale.cs new file mode 100644 index 000000000..51868605f --- /dev/null +++ b/API/DTOs/KavitaLocale.cs @@ -0,0 +1,10 @@ +namespace API.DTOs; + +public sealed record KavitaLocale +{ + public string FileName { get; set; } // Key + public string RenderName { get; set; } + public float TranslationCompletion { get; set; } + public bool IsRtL { get; set; } + public string Hash { get; set; } // ETAG hash so I can run my own localization busting implementation +} diff --git a/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs b/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs new file mode 100644 index 000000000..c053bd34e --- /dev/null +++ b/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs @@ -0,0 +1,6 @@ +namespace API.DTOs.KavitaPlus.Account; + +public sealed record AniListUpdateDto +{ + public string Token { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs b/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs new file mode 100644 index 000000000..340ad0f4c --- /dev/null +++ b/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs @@ -0,0 +1,16 @@ +using System; + +namespace API.DTOs.KavitaPlus.Account; + +/// +/// Represents information around a user's tokens and their status +/// +public sealed record UserTokenInfo +{ + public int UserId { get; set; } + public string Username { get; set; } + public bool IsAniListTokenSet { get; set; } + public bool IsAniListTokenValid { get; set; } + public DateTime AniListValidUntilUtc { get; set; } + public bool IsMalTokenSet { get; set; } +} diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs new file mode 100644 index 000000000..c05ff0567 --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs @@ -0,0 +1,17 @@ +using API.DTOs.Scrobbling; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; +#nullable enable + +/// +/// Used for matching and fetching metadata on a series +/// +public sealed record ExternalMetadataIdsDto +{ + public long? MalId { get; set; } + public int? AniListId { get; set; } + + public string? SeriesName { get; set; } + public string? LocalizedSeriesName { get; set; } + public PlusMediaFormat? PlusMediaFormat { get; set; } = DTOs.Scrobbling.PlusMediaFormat.Unknown; +} diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs new file mode 100644 index 000000000..a7359d69b --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using API.DTOs.Scrobbling; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; +#nullable enable + +/// +/// Represents a request to match some series from Kavita to an external id which K+ uses. +/// +public sealed record MatchSeriesRequestDto +{ + public required string SeriesName { get; set; } + public ICollection AlternativeNames { get; set; } = []; + public int Year { get; set; } = 0; + public string? Query { get; set; } + public int? AniListId { get; set; } + public long? MalId { get; set; } + public string? HardcoverId { get; set; } + public int? CbrId { get; set; } + public PlusMediaFormat Format { get; set; } +} diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs new file mode 100644 index 000000000..84e9bbf3e --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; +using API.DTOs.Scrobbling; +using API.DTOs.SeriesDetail; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; + +public sealed record SeriesDetailPlusApiDto +{ + public IEnumerable Recommendations { get; set; } + public IEnumerable Reviews { get; set; } + public IEnumerable Ratings { get; set; } + public ExternalSeriesDetailDto? Series { get; set; } + public int? AniListId { get; set; } + public long? MalId { get; set; } + public int? CbrId { get; set; } +} diff --git a/API/DTOs/License/EncryptLicenseDto.cs b/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs similarity index 66% rename from API/DTOs/License/EncryptLicenseDto.cs rename to API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs index 97015c470..dd85dd063 100644 --- a/API/DTOs/License/EncryptLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs @@ -1,6 +1,7 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; +#nullable enable -public class EncryptLicenseDto +public sealed record EncryptLicenseDto { public required string License { get; set; } public required string InstallId { get; set; } diff --git a/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs new file mode 100644 index 000000000..2cd9b5896 --- /dev/null +++ b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs @@ -0,0 +1,35 @@ +using System; + +namespace API.DTOs.KavitaPlus.License; + +public sealed record LicenseInfoDto +{ + /// + /// If cancelled, will represent cancellation date. If not, will represent repayment date + /// + public DateTime ExpirationDate { get; set; } + /// + /// If cancelled or not + /// + public bool IsActive { get; set; } + /// + /// If will be or is cancelled + /// + public bool IsCancelled { get; set; } + /// + /// Is the installed version valid for Kavita+ (aka within 3 releases) + /// + public bool IsValidVersion { get; set; } + /// + /// The email on file + /// + public string RegisteredEmail { get; set; } + /// + /// Number of months user has been subscribed + /// + public int TotalMonthsSubbed { get; set; } + /// + /// A license is stored within Kavita + /// + public bool HasLicense { get; set; } +} diff --git a/API/DTOs/Account/LicenseValidDto.cs b/API/DTOs/KavitaPlus/License/LicenseValidDto.cs similarity index 57% rename from API/DTOs/Account/LicenseValidDto.cs rename to API/DTOs/KavitaPlus/License/LicenseValidDto.cs index f49420779..a7bd476ce 100644 --- a/API/DTOs/Account/LicenseValidDto.cs +++ b/API/DTOs/KavitaPlus/License/LicenseValidDto.cs @@ -1,6 +1,6 @@ -namespace API.DTOs.Account; +namespace API.DTOs.KavitaPlus.License; -public class LicenseValidDto +public sealed record LicenseValidDto { public required string License { get; set; } public required string InstallId { get; set; } diff --git a/API/DTOs/License/ResetLicenseDto.cs b/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs similarity index 66% rename from API/DTOs/License/ResetLicenseDto.cs rename to API/DTOs/KavitaPlus/License/ResetLicenseDto.cs index f62d78870..d0fd9b666 100644 --- a/API/DTOs/License/ResetLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs @@ -1,6 +1,6 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; -public class ResetLicenseDto +public sealed record ResetLicenseDto { public required string License { get; set; } public required string InstallId { get; set; } diff --git a/API/DTOs/License/UpdateLicenseDto.cs b/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs similarity index 78% rename from API/DTOs/License/UpdateLicenseDto.cs rename to API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs index b2803952c..28b47efbe 100644 --- a/API/DTOs/License/UpdateLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs @@ -1,6 +1,7 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; +#nullable enable -public class UpdateLicenseDto +public sealed record UpdateLicenseDto { /// /// License Key received from Kavita+ diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs new file mode 100644 index 000000000..c394cf8d4 --- /dev/null +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs @@ -0,0 +1,23 @@ +namespace API.DTOs.KavitaPlus.Manage; + +/// +/// Represents an option in the UI layer for Filtering +/// +public enum MatchStateOption +{ + All = 0, + Matched = 1, + NotMatched = 2, + Error = 3, + DontMatch = 4 +} + +public sealed record ManageMatchFilterDto +{ + public MatchStateOption MatchStateOption { get; set; } = MatchStateOption.All; + /// + /// Library Type in int form. -1 indicates to ignore the field. + /// + public int LibraryType { get; set; } = -1; + public string SearchTerm { get; set; } = string.Empty; +} diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs new file mode 100644 index 000000000..a51e63ee9 --- /dev/null +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace API.DTOs.KavitaPlus.Manage; + +public sealed record ManageMatchSeriesDto +{ + public SeriesDto Series { get; set; } + public bool IsMatched { get; set; } + public DateTime ValidUntilUtc { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs new file mode 100644 index 000000000..add9ca723 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using API.DTOs.SeriesDetail; + +namespace API.DTOs.KavitaPlus.Metadata; +#nullable enable + +/// +/// Information about an individual issue/chapter/book from Kavita+ +/// +public sealed record ExternalChapterDto +{ + public string Title { get; set; } + + public string IssueNumber { get; set; } + + public decimal? CriticRating { get; set; } + + public decimal? UserRating { get; set; } + + public string? Summary { get; set; } + + public IList? Writers { get; set; } + + public IList? Artists { get; set; } + + public DateTime? ReleaseDate { get; set; } + + public string? Publisher { get; set; } + + public string? CoverImageUrl { get; set; } + + public string? IssueUrl { get; set; } + + public IList CriticReviews { get; set; } + public IList UserReviews { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs new file mode 100644 index 000000000..6704bf697 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using API.DTOs.Recommendation; +using API.DTOs.Scrobbling; +using API.Services.Plus; + +namespace API.DTOs.KavitaPlus.Metadata; +#nullable enable + +/// +/// This is AniListSeries +/// +public sealed record ExternalSeriesDetailDto +{ + public string Name { get; set; } + public int? AniListId { get; set; } + public long? MALId { get; set; } + public int? CbrId { get; set; } + public IList Synonyms { get; set; } = []; + public PlusMediaFormat PlusMediaFormat { get; set; } + public string? SiteUrl { get; set; } + public string? CoverUrl { get; set; } + public IList Genres { get; set; } + public IList Staff { get; set; } + public IList Tags { get; set; } + public string? Summary { get; set; } + public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList; + + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int AverageScore { get; set; } + /// AniList returns the total count of unique chapters, includes 1.1 for example + public int Chapters { get; set; } + /// AniList returns the total count of unique volumes, includes 1.1 for example + public int Volumes { get; set; } + public IList? Relations { get; set; } = []; + public IList? Characters { get; set; } = []; + + #region Comic Only + public string? Publisher { get; set; } + /// + /// Only from CBR for . Full metadata about issues + /// + public IList? ChapterDtos { get; set; } + #endregion + + +} diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs new file mode 100644 index 000000000..a9debabd1 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs @@ -0,0 +1,22 @@ +using API.Entities.Enums; + +namespace API.DTOs.KavitaPlus.Metadata; + +public sealed record MetadataFieldMappingDto +{ + public int Id { get; set; } + public MetadataFieldType SourceType { get; set; } + public MetadataFieldType DestinationType { get; set; } + /// + /// The string in the source + /// + public string SourceValue { get; set; } + /// + /// Write the string as this in the Destination (can also just be the Source) + /// + public string DestinationValue { get; set; } + /// + /// If true, the tag will be Moved over vs Copied over + /// + public bool ExcludeFromSource { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs new file mode 100644 index 000000000..e9f6614bc --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; +using API.Entities.MetadataMatching; +using NotImplementedException = System.NotImplementedException; + +namespace API.DTOs.KavitaPlus.Metadata; + + +public sealed record MetadataSettingsDto +{ + /// + /// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed + /// + public bool Enabled { get; set; } + + /// + /// Allow the Summary to be written + /// + public bool EnableSummary { get; set; } + /// + /// Allow Publication status to be derived and updated + /// + public bool EnablePublicationStatus { get; set; } + /// + /// Allow Relationships between series to be set + /// + public bool EnableRelationships { get; set; } + /// + /// Allow People to be created (including downloading images) + /// + public bool EnablePeople { get; set; } + /// + /// Allow Start date to be set within the Series + /// + public bool EnableStartDate { get; set; } + /// + /// Allow setting the Localized name + /// + public bool EnableLocalizedName { get; set; } + /// + /// Allow setting the cover image + /// + public bool EnableCoverImage { get; set; } + + #region Chapter Metadata + /// + /// Allow Summary to be set within Chapter/Issue + /// + public bool EnableChapterSummary { get; set; } + /// + /// Allow Release Date to be set within Chapter/Issue + /// + public bool EnableChapterReleaseDate { get; set; } + /// + /// Allow Title to be set within Chapter/Issue + /// + public bool EnableChapterTitle { get; set; } + /// + /// Allow Publisher to be set within Chapter/Issue + /// + public bool EnableChapterPublisher { get; set; } + /// + /// Allow setting the cover image for the Chapter/Issue + /// + public bool EnableChapterCoverImage { get; set; } + #endregion + + // Need to handle the Genre/tags stuff + public bool EnableGenres { get; set; } = true; + public bool EnableTags { get; set; } = true; + + /// + /// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names. + /// + public bool FirstLastPeopleNaming { get; set; } + + /// + /// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching. + /// + public Dictionary AgeRatingMappings { get; set; } + + /// + /// A list of rules that allow mapping a genre/tag to another genre/tag + /// + public List FieldMappings { get; set; } + /// + /// A list of overrides that will enable writing to locked fields + /// + public List Overrides { get; set; } + + /// + /// Do not allow any Genre/Tag in this list to be written to Kavita + /// + public List Blacklist { get; set; } + /// + /// Only allow these Tags to be written to Kavita + /// + public List Whitelist { get; set; } + /// + /// Which Roles to allow metadata downloading for + /// + public List PersonRoles { get; set; } + + + /// + /// Override list contains this field + /// + /// + /// + public bool HasOverride(MetadataSettingField field) + { + return Overrides.Contains(field); + } + + /// + /// If this Person role is allowed to be written + /// + /// + /// + public bool IsPersonAllowed(PersonRole character) + { + return PersonRoles.Contains(character); + } +} diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs new file mode 100644 index 000000000..2b57548cd --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs @@ -0,0 +1,19 @@ +namespace API.DTOs.KavitaPlus.Metadata; +#nullable enable + +public enum CharacterRole +{ + Main = 0, + Supporting = 1, + Background = 2 +} + + +public sealed record SeriesCharacter +{ + public string Name { get; set; } + public required string Description { get; set; } + public required string Url { get; set; } + public string? ImageUrl { get; set; } + public CharacterRole Role { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs b/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs new file mode 100644 index 000000000..0b1f619a2 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs @@ -0,0 +1,24 @@ +using API.DTOs.Scrobbling; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Services.Plus; + +namespace API.DTOs.KavitaPlus.Metadata; + +public sealed record ALMediaTitle +{ + public string? EnglishTitle { get; set; } + public string RomajiTitle { get; set; } + public string NativeTitle { get; set; } + public string PreferredTitle { get; set; } +} + +public sealed record SeriesRelationship +{ + public int AniListId { get; set; } + public int? MalId { get; set; } + public ALMediaTitle SeriesName { get; set; } + public RelationKind Relation { get; set; } + public ScrobbleProvider Provider { get; set; } + public PlusMediaFormat PlusMediaFormat { get; set; } = PlusMediaFormat.Manga; +} diff --git a/API/DTOs/Koreader/KoreaderBookDto.cs b/API/DTOs/Koreader/KoreaderBookDto.cs new file mode 100644 index 000000000..b66b7da3a --- /dev/null +++ b/API/DTOs/Koreader/KoreaderBookDto.cs @@ -0,0 +1,33 @@ +using API.DTOs.Progress; + +namespace API.DTOs.Koreader; + +/// +/// This is the interface for receiving and sending updates to Koreader. The only fields +/// that are actually used are the Document and Progress fields. +/// +public class KoreaderBookDto +{ + /// + /// This is the Koreader hash of the book. It is used to identify the book. + /// + public string Document { get; set; } + /// + /// A randomly generated id from the koreader device. Only used to maintain the Koreader interface. + /// + public string Device_id { get; set; } + /// + /// The Koreader device name. Only used to maintain the Koreader interface. + /// + public string Device { get; set; } + /// + /// Percent progress of the book. Only used to maintain the Koreader interface. + /// + public float Percentage { get; set; } + /// + /// An XPath string read by Koreader to determine the location within the epub. + /// Essentially, it is Koreader's equivalent to ProgressDto.BookScrollId. + /// + /// + public string Progress { get; set; } +} diff --git a/API/DTOs/Koreader/KoreaderProgressUpdateDto.cs b/API/DTOs/Koreader/KoreaderProgressUpdateDto.cs new file mode 100644 index 000000000..52a1d6cbd --- /dev/null +++ b/API/DTOs/Koreader/KoreaderProgressUpdateDto.cs @@ -0,0 +1,15 @@ +using System; + +namespace API.DTOs.Koreader; + +public class KoreaderProgressUpdateDto +{ + /// + /// This is the Koreader hash of the book. It is used to identify the book. + /// + public string Document { get; set; } + /// + /// UTC Timestamp to return to KOReader + /// + public DateTime Timestamp { get; set; } +} diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index c8c85063e..bd72ad2f0 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -1,12 +1,11 @@ using System; -using System.Collections; using System.Collections.Generic; using API.Entities.Enums; namespace API.DTOs; #nullable enable -public class LibraryDto +public sealed record LibraryDto { public int Id { get; init; } public string? Name { get; init; } @@ -61,4 +60,18 @@ public class LibraryDto /// A set of globs that will exclude matching content from being scanned /// public ICollection ExcludePatterns { get; set; } + /// + /// Allow any series within this Library to download metadata. + /// + /// This does not exclude the library from being linked to wrt Series Relationships + /// Requires a valid LicenseKey + public bool AllowMetadataMatching { get; set; } = true; + /// + /// Allow Kavita to read metadata (ComicInfo.xml, Epub, PDF) + /// + public bool EnableMetadata { get; set; } = true; + /// + /// Should Kavita remove sort articles "The" for the sort name + /// + public bool RemovePrefixForSortName { get; set; } = false; } diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index 3f0983067..23bb37467 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -2,8 +2,9 @@ using API.Entities.Enums; namespace API.DTOs; +#nullable enable -public class MangaFileDto +public sealed record MangaFileDto { public int Id { get; init; } /// diff --git a/API/DTOs/MediaErrors/MediaErrorDto.cs b/API/DTOs/MediaErrors/MediaErrorDto.cs index bfaf57124..b77ee88be 100644 --- a/API/DTOs/MediaErrors/MediaErrorDto.cs +++ b/API/DTOs/MediaErrors/MediaErrorDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.MediaErrors; -public class MediaErrorDto +public sealed record MediaErrorDto { /// /// Format Type (RAR, ZIP, 7Zip, Epub, PDF) diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index 7b750b32f..f5f24b284 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -8,7 +8,7 @@ namespace API.DTOs; /// /// Represents a member of a Kavita server. /// -public class MemberDto +public sealed record MemberDto { public int Id { get; init; } public string? Username { get; init; } diff --git a/API/DTOs/Metadata/AgeRatingDto.cs b/API/DTOs/Metadata/AgeRatingDto.cs index 07523c3fe..bfa835ef5 100644 --- a/API/DTOs/Metadata/AgeRatingDto.cs +++ b/API/DTOs/Metadata/AgeRatingDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Metadata; -public class AgeRatingDto +public sealed record AgeRatingDto { public AgeRating Value { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/Browse/BrowseGenreDto.cs b/API/DTOs/Metadata/Browse/BrowseGenreDto.cs new file mode 100644 index 000000000..8044c7914 --- /dev/null +++ b/API/DTOs/Metadata/Browse/BrowseGenreDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Metadata.Browse; + +public sealed record BrowseGenreDto : GenreTagDto +{ + /// + /// Number of Series this Entity is on + /// + public int SeriesCount { get; set; } + /// + /// Number of Chapters this Entity is on + /// + public int ChapterCount { get; set; } +} diff --git a/API/DTOs/Person/BrowsePersonDto.cs b/API/DTOs/Metadata/Browse/BrowsePersonDto.cs similarity index 65% rename from API/DTOs/Person/BrowsePersonDto.cs rename to API/DTOs/Metadata/Browse/BrowsePersonDto.cs index 8d6999973..20f84b783 100644 --- a/API/DTOs/Person/BrowsePersonDto.cs +++ b/API/DTOs/Metadata/Browse/BrowsePersonDto.cs @@ -1,4 +1,6 @@ -namespace API.DTOs; +using API.DTOs.Person; + +namespace API.DTOs.Metadata.Browse; /// /// Used to browse writers and click in to see their series @@ -10,7 +12,7 @@ public class BrowsePersonDto : PersonDto /// public int SeriesCount { get; set; } /// - /// Number or Issues this Person is the Writer for + /// Number of Issues this Person is the Writer for /// - public int IssueCount { get; set; } + public int ChapterCount { get; set; } } diff --git a/API/DTOs/Metadata/Browse/BrowseTagDto.cs b/API/DTOs/Metadata/Browse/BrowseTagDto.cs new file mode 100644 index 000000000..9a71876e3 --- /dev/null +++ b/API/DTOs/Metadata/Browse/BrowseTagDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Metadata.Browse; + +public sealed record BrowseTagDto : TagDto +{ + /// + /// Number of Series this Entity is on + /// + public int SeriesCount { get; set; } + /// + /// Number of Chapters this Entity is on + /// + public int ChapterCount { get; set; } +} diff --git a/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs b/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs new file mode 100644 index 000000000..d41cf37f3 --- /dev/null +++ b/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; + +namespace API.DTOs.Metadata.Browse.Requests; +#nullable enable + +public sealed record BrowsePersonFilterDto +{ + /// + /// Not used - For parity with Series Filter + /// + public int Id { get; set; } + /// + /// Not used - For parity with Series Filter + /// + public string? Name { get; set; } + public ICollection Statements { get; set; } = []; + public FilterCombination Combination { get; set; } = FilterCombination.And; + public PersonSortOptions? SortOptions { get; set; } + + /// + /// Limit the number of rows returned. Defaults to not applying a limit (aka 0) + /// + public int LimitTo { get; set; } = 0; +} diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs index bbd93d618..c79436e24 100644 --- a/API/DTOs/Metadata/ChapterMetadataDto.cs +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs.Metadata; @@ -9,7 +10,7 @@ namespace API.DTOs.Metadata; /// Exclusively metadata about a given chapter /// [Obsolete("Will not be maintained as of v0.8.1")] -public class ChapterMetadataDto +public sealed record ChapterMetadataDto { public int Id { get; set; } public int ChapterId { get; set; } diff --git a/API/DTOs/Metadata/GenreTagDto.cs b/API/DTOs/Metadata/GenreTagDto.cs index cf05ebbff..13a339d38 100644 --- a/API/DTOs/Metadata/GenreTagDto.cs +++ b/API/DTOs/Metadata/GenreTagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Metadata; -public class GenreTagDto +public record GenreTagDto { public int Id { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs b/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs new file mode 100644 index 000000000..774581b37 --- /dev/null +++ b/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs @@ -0,0 +1,10 @@ +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; + +namespace API.DTOs.Metadata.Matching; + +public sealed record ExternalSeriesMatchDto +{ + public ExternalSeriesDetailDto Series { get; set; } + public float MatchRating { get; set; } +} diff --git a/API/DTOs/Metadata/Matching/MatchSeriesDto.cs b/API/DTOs/Metadata/Matching/MatchSeriesDto.cs new file mode 100644 index 000000000..bb497b9ab --- /dev/null +++ b/API/DTOs/Metadata/Matching/MatchSeriesDto.cs @@ -0,0 +1,20 @@ +namespace API.DTOs.Metadata.Matching; + +/// +/// Used for matching a series with Kavita+ for metadata and scrobbling +/// +public sealed record MatchSeriesDto +{ + /// + /// When set, Kavita will stop attempting to match this series and will not perform any scrobbling + /// + public bool DontMatch { get; set; } + /// + /// Series Id to pull internal metadata from to improve matching + /// + public int SeriesId { get; set; } + /// + /// Free form text to query for. Can be a url and ids will be parsed from it + /// + public string Query { get; set; } +} diff --git a/API/DTOs/Metadata/PublicationStatusDto.cs b/API/DTOs/Metadata/PublicationStatusDto.cs index b8166a6e5..b4f12500a 100644 --- a/API/DTOs/Metadata/PublicationStatusDto.cs +++ b/API/DTOs/Metadata/PublicationStatusDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Metadata; -public class PublicationStatusDto +public sealed record PublicationStatusDto { public PublicationStatus Value { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/TagDto.cs b/API/DTOs/Metadata/TagDto.cs index 59e03a279..f5c925e1f 100644 --- a/API/DTOs/Metadata/TagDto.cs +++ b/API/DTOs/Metadata/TagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Metadata; -public class TagDto +public record TagDto { public int Id { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/OPDS/Feed.cs b/API/DTOs/OPDS/Feed.cs index 76a740b89..5f4c4b115 100644 --- a/API/DTOs/OPDS/Feed.cs +++ b/API/DTOs/OPDS/Feed.cs @@ -4,11 +4,13 @@ using System.Xml.Serialization; namespace API.DTOs.OPDS; +// TODO: OPDS Dtos are internal state, shouldn't be in DTO directory + /// /// /// [XmlRoot("feed", Namespace = "http://www.w3.org/2005/Atom")] -public class Feed +public sealed record Feed { [XmlElement("updated")] public string Updated { get; init; } = DateTime.UtcNow.ToString("s"); diff --git a/API/DTOs/OPDS/FeedAuthor.cs b/API/DTOs/OPDS/FeedAuthor.cs index 1fd3e6cd2..4196997dd 100644 --- a/API/DTOs/OPDS/FeedAuthor.cs +++ b/API/DTOs/OPDS/FeedAuthor.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class FeedAuthor +public sealed record FeedAuthor { [XmlElement("name")] public string Name { get; set; } diff --git a/API/DTOs/OPDS/FeedCategory.cs b/API/DTOs/OPDS/FeedCategory.cs index 3129fab60..2352b4af2 100644 --- a/API/DTOs/OPDS/FeedCategory.cs +++ b/API/DTOs/OPDS/FeedCategory.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class FeedCategory +public sealed record FeedCategory { [XmlAttribute("scheme")] public string Scheme { get; } = "http://www.bisg.org/standards/bisac_subject/index.html"; diff --git a/API/DTOs/OPDS/FeedEntry.cs b/API/DTOs/OPDS/FeedEntry.cs index da8b53b74..838ebd124 100644 --- a/API/DTOs/OPDS/FeedEntry.cs +++ b/API/DTOs/OPDS/FeedEntry.cs @@ -5,7 +5,7 @@ using System.Xml.Serialization; namespace API.DTOs.OPDS; #nullable enable -public class FeedEntry +public sealed record FeedEntry { [XmlElement("updated")] public string Updated { get; init; } = DateTime.UtcNow.ToString("s"); diff --git a/API/DTOs/OPDS/FeedEntryContent.cs b/API/DTOs/OPDS/FeedEntryContent.cs index 3e95ce643..4de9b73bd 100644 --- a/API/DTOs/OPDS/FeedEntryContent.cs +++ b/API/DTOs/OPDS/FeedEntryContent.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class FeedEntryContent +public sealed record FeedEntryContent { [XmlAttribute("type")] public string Type = "text"; diff --git a/API/DTOs/OPDS/FeedLink.cs b/API/DTOs/OPDS/FeedLink.cs index cff3b6736..28c55bbe8 100644 --- a/API/DTOs/OPDS/FeedLink.cs +++ b/API/DTOs/OPDS/FeedLink.cs @@ -3,7 +3,7 @@ using System.Xml.Serialization; namespace API.DTOs.OPDS; -public class FeedLink +public sealed record FeedLink { [XmlIgnore] public bool IsPageStream { get; set; } diff --git a/API/DTOs/OPDS/OpenSearchDescription.cs b/API/DTOs/OPDS/OpenSearchDescription.cs index cc8392a88..eba26572f 100644 --- a/API/DTOs/OPDS/OpenSearchDescription.cs +++ b/API/DTOs/OPDS/OpenSearchDescription.cs @@ -3,7 +3,7 @@ namespace API.DTOs.OPDS; [XmlRoot("OpenSearchDescription", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] -public class OpenSearchDescription +public sealed record OpenSearchDescription { /// /// Contains a brief human-readable title that identifies this search engine. diff --git a/API/DTOs/OPDS/SearchLink.cs b/API/DTOs/OPDS/SearchLink.cs index dba67f3bd..b4698c221 100644 --- a/API/DTOs/OPDS/SearchLink.cs +++ b/API/DTOs/OPDS/SearchLink.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS; -public class SearchLink +public sealed record SearchLink { [XmlAttribute("type")] public string Type { get; set; } = default!; diff --git a/API/DTOs/Person/PersonDto.cs b/API/DTOs/Person/PersonDto.cs index aa0f0680c..db152e3b1 100644 --- a/API/DTOs/Person/PersonDto.cs +++ b/API/DTOs/Person/PersonDto.cs @@ -1,4 +1,7 @@ -namespace API.DTOs; +using System.Collections.Generic; + +namespace API.DTOs.Person; +#nullable enable public class PersonDto { @@ -6,12 +9,13 @@ public class PersonDto public required string Name { get; set; } public bool CoverImageLocked { get; set; } - public string PrimaryColor { get; set; } - public string SecondaryColor { get; set; } + public string? PrimaryColor { get; set; } + public string? SecondaryColor { get; set; } public string? CoverImage { get; set; } + public List Aliases { get; set; } = []; - public string Description { get; set; } + public string? Description { get; set; } /// /// ASIN for person /// diff --git a/API/DTOs/Person/PersonMergeDto.cs b/API/DTOs/Person/PersonMergeDto.cs new file mode 100644 index 000000000..b5dc23375 --- /dev/null +++ b/API/DTOs/Person/PersonMergeDto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs; + +public sealed record PersonMergeDto +{ + /// + /// The id of the person being merged into + /// + [Required] + public int DestId { get; init; } + /// + /// The id of the person being merged. This person will be removed, and become an alias of + /// + [Required] + public int SrcId { get; init; } +} diff --git a/API/DTOs/Person/UpdatePersonDto.cs b/API/DTOs/Person/UpdatePersonDto.cs index fe5763257..b43a45e88 100644 --- a/API/DTOs/Person/UpdatePersonDto.cs +++ b/API/DTOs/Person/UpdatePersonDto.cs @@ -1,13 +1,18 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; namespace API.DTOs; +#nullable enable -public class UpdatePersonDto +public sealed record UpdatePersonDto { [Required] public int Id { get; init; } [Required] public bool CoverImageLocked { get; set; } + [Required] + public string Name {get; set;} + public IList Aliases { get; set; } = []; public string? Description { get; set; } public int? AniListId { get; set; } diff --git a/API/DTOs/Progress/FullProgressDto.cs b/API/DTOs/Progress/FullProgressDto.cs index 7d0b47f60..4f97ab44a 100644 --- a/API/DTOs/Progress/FullProgressDto.cs +++ b/API/DTOs/Progress/FullProgressDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Progress; /// /// A full progress Record from the DB (not all data, only what's needed for API) /// -public class FullProgressDto +public sealed record FullProgressDto { public int Id { get; set; } public int ChapterId { get; set; } diff --git a/API/DTOs/Progress/ProgressDto.cs b/API/DTOs/Progress/ProgressDto.cs index 9fc9010aa..0add848c5 100644 --- a/API/DTOs/Progress/ProgressDto.cs +++ b/API/DTOs/Progress/ProgressDto.cs @@ -4,7 +4,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.Progress; #nullable enable -public class ProgressDto +public sealed record ProgressDto { [Required] public int VolumeId { get; set; } diff --git a/API/DTOs/Progress/UpdateUserProgressDto.cs b/API/DTOs/Progress/UpdateUserProgressDto.cs deleted file mode 100644 index 2aa77b04e..000000000 --- a/API/DTOs/Progress/UpdateUserProgressDto.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -namespace API.DTOs.Progress; -#nullable enable - -public class UpdateUserProgressDto -{ - public int PageNum { get; set; } - public DateTime LastModifiedUtc { get; set; } - public DateTime CreatedUtc { get; set; } -} diff --git a/API/DTOs/RatingDto.cs b/API/DTOs/RatingDto.cs index e2cd9d342..101aa7ac5 100644 --- a/API/DTOs/RatingDto.cs +++ b/API/DTOs/RatingDto.cs @@ -1,12 +1,18 @@ -using API.Services.Plus; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Services.Plus; namespace API.DTOs; #nullable enable -public class RatingDto +public sealed record RatingDto { + public int AverageScore { get; set; } public int FavoriteCount { get; set; } public ScrobbleProvider Provider { get; set; } + /// + public RatingAuthority Authority { get; set; } = RatingAuthority.User; public string? ProviderUrl { get; set; } } diff --git a/API/DTOs/Reader/BookChapterItem.cs b/API/DTOs/Reader/BookChapterItem.cs index dcfb7b904..892e82e27 100644 --- a/API/DTOs/Reader/BookChapterItem.cs +++ b/API/DTOs/Reader/BookChapterItem.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class BookChapterItem +public sealed record BookChapterItem { /// /// Name of the Chapter diff --git a/API/DTOs/Reader/BookInfoDto.cs b/API/DTOs/Reader/BookInfoDto.cs index c379f71f8..2473cd5dc 100644 --- a/API/DTOs/Reader/BookInfoDto.cs +++ b/API/DTOs/Reader/BookInfoDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class BookInfoDto : IChapterInfoDto +public sealed record BookInfoDto : IChapterInfoDto { public string BookTitle { get; set; } = default! ; public int SeriesId { get; set; } diff --git a/API/DTOs/Reader/BookmarkDto.cs b/API/DTOs/Reader/BookmarkDto.cs index ef4cf3d6d..da18fc28e 100644 --- a/API/DTOs/Reader/BookmarkDto.cs +++ b/API/DTOs/Reader/BookmarkDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Reader; #nullable enable -public class BookmarkDto +public sealed record BookmarkDto { public int Id { get; set; } [Required] diff --git a/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs b/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs index 7490f837c..51ccf5cc3 100644 --- a/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs +++ b/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class BulkRemoveBookmarkForSeriesDto +public sealed record BulkRemoveBookmarkForSeriesDto { public ICollection SeriesIds { get; init; } = default!; } diff --git a/API/DTOs/Reader/ChapterInfoDto.cs b/API/DTOs/Reader/ChapterInfoDto.cs index 4584a5830..4da08a31d 100644 --- a/API/DTOs/Reader/ChapterInfoDto.cs +++ b/API/DTOs/Reader/ChapterInfoDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.Reader; /// /// Information about the Chapter for the Reader to render /// -public class ChapterInfoDto : IChapterInfoDto +public sealed record ChapterInfoDto : IChapterInfoDto { /// /// The Chapter Number diff --git a/API/DTOs/Reader/CreatePersonalToCDto.cs b/API/DTOs/Reader/CreatePersonalToCDto.cs index 25526b490..95272ca58 100644 --- a/API/DTOs/Reader/CreatePersonalToCDto.cs +++ b/API/DTOs/Reader/CreatePersonalToCDto.cs @@ -1,6 +1,7 @@ namespace API.DTOs.Reader; +#nullable enable -public class CreatePersonalToCDto +public sealed record CreatePersonalToCDto { public required int ChapterId { get; set; } public required int VolumeId { get; set; } diff --git a/API/DTOs/Reader/FileDimensionDto.cs b/API/DTOs/Reader/FileDimensionDto.cs index baee20dd0..7a7d2978f 100644 --- a/API/DTOs/Reader/FileDimensionDto.cs +++ b/API/DTOs/Reader/FileDimensionDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class FileDimensionDto +public sealed record FileDimensionDto { public int Width { get; set; } public int Height { get; set; } diff --git a/API/DTOs/Reader/HourEstimateRangeDto.cs b/API/DTOs/Reader/HourEstimateRangeDto.cs index 8c8bd11a9..3facf8e56 100644 --- a/API/DTOs/Reader/HourEstimateRangeDto.cs +++ b/API/DTOs/Reader/HourEstimateRangeDto.cs @@ -3,7 +3,7 @@ /// /// A range of time to read a selection (series, chapter, etc) /// -public record HourEstimateRangeDto +public sealed record HourEstimateRangeDto { /// /// Min hours to read the selection diff --git a/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs b/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs index 50187ec81..4c39f7d76 100644 --- a/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs +++ b/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Reader; -public class MarkMultipleSeriesAsReadDto +public sealed record MarkMultipleSeriesAsReadDto { public IReadOnlyList SeriesIds { get; init; } = default!; } diff --git a/API/DTOs/Reader/MarkReadDto.cs b/API/DTOs/Reader/MarkReadDto.cs index 9bf46a6d5..c6f7367c0 100644 --- a/API/DTOs/Reader/MarkReadDto.cs +++ b/API/DTOs/Reader/MarkReadDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class MarkReadDto +public sealed record MarkReadDto { public int SeriesId { get; init; } } diff --git a/API/DTOs/Reader/MarkVolumeReadDto.cs b/API/DTOs/Reader/MarkVolumeReadDto.cs index 47ffd2649..be95d2e98 100644 --- a/API/DTOs/Reader/MarkVolumeReadDto.cs +++ b/API/DTOs/Reader/MarkVolumeReadDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class MarkVolumeReadDto +public sealed record MarkVolumeReadDto { public int SeriesId { get; init; } public int VolumeId { get; init; } diff --git a/API/DTOs/Reader/MarkVolumesReadDto.cs b/API/DTOs/Reader/MarkVolumesReadDto.cs index ebe1cd76c..b07bfbc67 100644 --- a/API/DTOs/Reader/MarkVolumesReadDto.cs +++ b/API/DTOs/Reader/MarkVolumesReadDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Reader; /// /// This is used for bulk updating a set of volume and or chapters in one go /// -public class MarkVolumesReadDto +public sealed record MarkVolumesReadDto { public int SeriesId { get; set; } /// diff --git a/API/DTOs/Reader/PersonalToCDto.cs b/API/DTOs/Reader/PersonalToCDto.cs index 144ed561f..c979d9d78 100644 --- a/API/DTOs/Reader/PersonalToCDto.cs +++ b/API/DTOs/Reader/PersonalToCDto.cs @@ -2,7 +2,7 @@ #nullable enable -public class PersonalToCDto +public sealed record PersonalToCDto { public required int ChapterId { get; set; } public required int PageNumber { get; set; } diff --git a/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs b/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs index ed6368a4f..ecbb744c8 100644 --- a/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs +++ b/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Reader; -public class RemoveBookmarkForSeriesDto +public sealed record RemoveBookmarkForSeriesDto { public int SeriesId { get; init; } } diff --git a/API/DTOs/ReadingLists/CBL/CblBook.cs b/API/DTOs/ReadingLists/CBL/CblBook.cs index 08930e208..d51795b8d 100644 --- a/API/DTOs/ReadingLists/CBL/CblBook.cs +++ b/API/DTOs/ReadingLists/CBL/CblBook.cs @@ -5,7 +5,7 @@ namespace API.DTOs.ReadingLists.CBL; [XmlRoot(ElementName="Book")] -public class CblBook +public sealed record CblBook { [XmlAttribute("Series")] public string Series { get; set; } diff --git a/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs b/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs index 70a002884..35234923f 100644 --- a/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs +++ b/API/DTOs/ReadingLists/CBL/CblConflictsDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.ReadingLists.CBL; -public class CblConflictQuestion +public sealed record CblConflictQuestion { public string SeriesName { get; set; } public IList LibrariesIds { get; set; } diff --git a/API/DTOs/ReadingLists/CBL/CblImportSummary.cs b/API/DTOs/ReadingLists/CBL/CblImportSummary.cs index 136a31aa8..b9716421e 100644 --- a/API/DTOs/ReadingLists/CBL/CblImportSummary.cs +++ b/API/DTOs/ReadingLists/CBL/CblImportSummary.cs @@ -75,7 +75,7 @@ public enum CblImportReason InvalidFile = 9, } -public class CblBookResult +public sealed record CblBookResult { /// /// Order in the CBL @@ -114,7 +114,7 @@ public class CblBookResult /// /// Represents the summary from the Import of a given CBL /// -public class CblImportSummaryDto +public sealed record CblImportSummaryDto { public string CblName { get; set; } /// diff --git a/API/DTOs/ReadingLists/CBL/CblReadingList.cs b/API/DTOs/ReadingLists/CBL/CblReadingList.cs index 001e6434b..15b349f42 100644 --- a/API/DTOs/ReadingLists/CBL/CblReadingList.cs +++ b/API/DTOs/ReadingLists/CBL/CblReadingList.cs @@ -5,7 +5,7 @@ namespace API.DTOs.ReadingLists.CBL; [XmlRoot(ElementName="Books")] -public class CblBooks +public sealed record CblBooks { [XmlElement(ElementName="Book")] public List Book { get; set; } @@ -13,7 +13,7 @@ public class CblBooks [XmlRoot(ElementName="ReadingList")] -public class CblReadingList +public sealed record CblReadingList { /// /// Name of the Reading List diff --git a/API/DTOs/ReadingLists/CreateReadingListDto.cs b/API/DTOs/ReadingLists/CreateReadingListDto.cs index 783253007..543215722 100644 --- a/API/DTOs/ReadingLists/CreateReadingListDto.cs +++ b/API/DTOs/ReadingLists/CreateReadingListDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class CreateReadingListDto +public sealed record CreateReadingListDto { public string Title { get; init; } = default!; } diff --git a/API/DTOs/ReadingLists/DeleteReadingListsDto.cs b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs index 8417f8132..8ce92f939 100644 --- a/API/DTOs/ReadingLists/DeleteReadingListsDto.cs +++ b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.ReadingLists; -public class DeleteReadingListsDto +public sealed record DeleteReadingListsDto { [Required] public IList ReadingListIds { get; set; } diff --git a/API/DTOs/ReadingLists/PromoteReadingListsDto.cs b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs index f64bbb5ca..8915274de 100644 --- a/API/DTOs/ReadingLists/PromoteReadingListsDto.cs +++ b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class PromoteReadingListsDto +public sealed record PromoteReadingListsDto { public IList ReadingListIds { get; init; } public bool Promoted { get; init; } diff --git a/API/DTOs/ReadingLists/ReadingListCast.cs b/API/DTOs/ReadingLists/ReadingListCast.cs new file mode 100644 index 000000000..855bb12b7 --- /dev/null +++ b/API/DTOs/ReadingLists/ReadingListCast.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using API.DTOs.Person; + +namespace API.DTOs.ReadingLists; + +public sealed record ReadingListCast +{ + public ICollection Writers { get; set; } = []; + public ICollection CoverArtists { get; set; } = []; + public ICollection Publishers { get; set; } = []; + public ICollection Characters { get; set; } = []; + public ICollection Pencillers { get; set; } = []; + public ICollection Inkers { get; set; } = []; + public ICollection Imprints { get; set; } = []; + public ICollection Colorists { get; set; } = []; + public ICollection Letterers { get; set; } = []; + public ICollection Editors { get; set; } = []; + public ICollection Translators { get; set; } = []; + public ICollection Teams { get; set; } = []; + public ICollection Locations { get; set; } = []; +} diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index 14609b4ae..47a526411 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -1,10 +1,11 @@ using System; +using API.Entities.Enums; using API.Entities.Interfaces; namespace API.DTOs.ReadingLists; #nullable enable -public class ReadingListDto : IHasCoverImage +public sealed record ReadingListDto : IHasCoverImage { public int Id { get; init; } public string Title { get; set; } = default!; @@ -19,8 +20,13 @@ public class ReadingListDto : IHasCoverImage /// public string? CoverImage { get; set; } = string.Empty; - public string PrimaryColor { get; set; } = string.Empty; - public string SecondaryColor { get; set; } = string.Empty; + public string? PrimaryColor { get; set; } = string.Empty; + public string? SecondaryColor { get; set; } = string.Empty; + + /// + /// Number of Items in the Reading List + /// + public int ItemCount { get; set; } /// /// Minimum Year the Reading List starts @@ -38,6 +44,15 @@ public class ReadingListDto : IHasCoverImage /// Maximum Month the Reading List starts /// public int EndingMonth { get; set; } + /// + /// The highest age rating from all Series within the reading list + /// + public required AgeRating AgeRating { get; set; } = AgeRating.Unknown; + + /// + /// Username of the User that owns (in the case of a promoted list) + /// + public string OwnerUserName { get; set; } public void ResetColorScape() { diff --git a/API/DTOs/ReadingLists/ReadingListInfoDto.cs b/API/DTOs/ReadingLists/ReadingListInfoDto.cs new file mode 100644 index 000000000..64a305f43 --- /dev/null +++ b/API/DTOs/ReadingLists/ReadingListInfoDto.cs @@ -0,0 +1,26 @@ +using API.DTOs.Reader; +using API.Entities.Interfaces; + +namespace API.DTOs.ReadingLists; + +public sealed record ReadingListInfoDto : IHasReadTimeEstimate +{ + /// + /// Total Pages across all Reading List Items + /// + public int Pages { get; set; } + /// + /// Total Word count across all Reading List Items + /// + public long WordCount { get; set; } + /// + /// Are ALL Reading List Items epub + /// + public bool IsAllEpub { get; set; } + /// + public int MinHoursToRead { get; set; } + /// + public int MaxHoursToRead { get; set; } + /// + public float AvgHoursToRead { get; set; } +} diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs index 893bf552b..8edec14f1 100644 --- a/API/DTOs/ReadingLists/ReadingListItemDto.cs +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.ReadingLists; #nullable enable -public class ReadingListItemDto +public sealed record ReadingListItemDto { public int Id { get; init; } public int Order { get; init; } @@ -25,7 +25,7 @@ public class ReadingListItemDto /// /// Release Date from Chapter /// - public DateTime ReleaseDate { get; set; } + public DateTime? ReleaseDate { get; set; } /// /// Used internally only /// @@ -33,7 +33,7 @@ public class ReadingListItemDto /// /// The last time a reading list item (underlying chapter) was read by current authenticated user /// - public DateTime LastReadingProgressUtc { get; set; } + public DateTime? LastReadingProgressUtc { get; set; } /// /// File size of underlying item /// @@ -43,4 +43,6 @@ public class ReadingListItemDto /// The chapter summary /// public string? Summary { get; set; } + + public bool IsSpecial { get; set; } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs index 985f86ac0..6624c8a5c 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByChapterDto +public sealed record UpdateReadingListByChapterDto { public int ChapterId { get; init; } public int SeriesId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs index 408963529..ba7625088 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByMultipleDto +public sealed record UpdateReadingListByMultipleDto { public int SeriesId { get; init; } public int ReadingListId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs index f910e9c06..910a5744d 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByMultipleSeriesDto +public sealed record UpdateReadingListByMultipleSeriesDto { public int ReadingListId { get; init; } public IReadOnlyList SeriesIds { get; init; } = default!; diff --git a/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs b/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs index 0590882bd..4bb4aa7bb 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListBySeriesDto +public sealed record UpdateReadingListBySeriesDto { public int SeriesId { get; init; } public int ReadingListId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs index f77c7d63a..422d1cc34 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListByVolumeDto +public sealed record UpdateReadingListByVolumeDto { public int VolumeId { get; init; } public int SeriesId { get; init; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListDto.cs b/API/DTOs/ReadingLists/UpdateReadingListDto.cs index 6b590707a..de273d825 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.ReadingLists; -public class UpdateReadingListDto +public sealed record UpdateReadingListDto { [Required] public int ReadingListId { get; set; } diff --git a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs index 3d0487144..04f2501a8 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs @@ -5,7 +5,7 @@ namespace API.DTOs.ReadingLists; /// /// DTO for moving a reading list item to another position within the same list /// -public class UpdateReadingListPosition +public sealed record UpdateReadingListPosition { [Required] public int ReadingListId { get; set; } [Required] public int ReadingListItemId { get; set; } diff --git a/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs b/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs deleted file mode 100644 index 9aa852fd7..000000000 --- a/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using API.DTOs.Scrobbling; -using API.Services.Plus; - -namespace API.DTOs.Recommendation; -#nullable enable - -public class ExternalSeriesDetailDto -{ - public string Name { get; set; } - public int? AniListId { get; set; } - public long? MALId { get; set; } - public IList Synonyms { get; set; } - public MediaFormat PlusMediaFormat { get; set; } - public string? SiteUrl { get; set; } - public string? CoverUrl { get; set; } - public IList Genres { get; set; } - public IList Staff { get; set; } - public IList Tags { get; set; } - public string? Summary { get; set; } - public int? VolumeCount { get; set; } - public int? ChapterCount { get; set; } - public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList; -} diff --git a/API/DTOs/Recommendation/ExternalSeriesDto.cs b/API/DTOs/Recommendation/ExternalSeriesDto.cs index 55d2d320c..752001a39 100644 --- a/API/DTOs/Recommendation/ExternalSeriesDto.cs +++ b/API/DTOs/Recommendation/ExternalSeriesDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Recommendation; #nullable enable -public class ExternalSeriesDto +public sealed record ExternalSeriesDto { public required string Name { get; set; } public required string CoverUrl { get; set; } @@ -12,4 +12,6 @@ public class ExternalSeriesDto public int? AniListId { get; set; } public long? MalId { get; set; } public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList; + + } diff --git a/API/DTOs/Recommendation/MetadataTagDto.cs b/API/DTOs/Recommendation/MetadataTagDto.cs index b219dedc1..a7eb76284 100644 --- a/API/DTOs/Recommendation/MetadataTagDto.cs +++ b/API/DTOs/Recommendation/MetadataTagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Recommendation; -public class MetadataTagDto +public sealed record MetadataTagDto { public string Name { get; set; } public string Description { get; private set; } diff --git a/API/DTOs/Recommendation/RecommendationDto.cs b/API/DTOs/Recommendation/RecommendationDto.cs index 679245a87..387661324 100644 --- a/API/DTOs/Recommendation/RecommendationDto.cs +++ b/API/DTOs/Recommendation/RecommendationDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Recommendation; -public class RecommendationDto +public sealed record RecommendationDto { public IList OwnedSeries { get; set; } = new List(); public IList ExternalSeries { get; set; } = new List(); diff --git a/API/DTOs/Recommendation/SeriesStaffDto.cs b/API/DTOs/Recommendation/SeriesStaffDto.cs index 0c1e9759d..e074e8625 100644 --- a/API/DTOs/Recommendation/SeriesStaffDto.cs +++ b/API/DTOs/Recommendation/SeriesStaffDto.cs @@ -1,9 +1,11 @@ namespace API.DTOs.Recommendation; #nullable enable -public class SeriesStaffDto +public sealed record SeriesStaffDto { public required string Name { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } public required string Url { get; set; } public required string Role { get; set; } public string? ImageUrl { get; set; } diff --git a/API/DTOs/RefreshSeriesDto.cs b/API/DTOs/RefreshSeriesDto.cs index 0e94fc44b..ad26afba2 100644 --- a/API/DTOs/RefreshSeriesDto.cs +++ b/API/DTOs/RefreshSeriesDto.cs @@ -3,7 +3,7 @@ /// /// Used for running some task against a Series. /// -public class RefreshSeriesDto +public sealed record RefreshSeriesDto { /// /// Library Id series belongs to diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index d0118e385..e117af872 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -1,15 +1,16 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs; +#nullable enable -public class RegisterDto +public sealed record RegisterDto { [Required] public string Username { get; init; } = default!; /// /// An email to register with. Optional. Provides Forgot Password functionality /// - public string Email { get; init; } = default!; + public string? Email { get; set; } = default!; [Required] [StringLength(256, MinimumLength = 6)] public string Password { get; set; } = default!; diff --git a/API/DTOs/ScanFolderDto.cs b/API/DTOs/ScanFolderDto.cs index 684de909e..141f7f0b5 100644 --- a/API/DTOs/ScanFolderDto.cs +++ b/API/DTOs/ScanFolderDto.cs @@ -3,7 +3,7 @@ /// /// DTO for requesting a folder to be scanned /// -public class ScanFolderDto +public sealed record ScanFolderDto { /// /// Api key for a user with Admin permissions diff --git a/API/DTOs/Scrobbling/MalUserInfoDto.cs b/API/DTOs/Scrobbling/MalUserInfoDto.cs index 407639e2a..b6fefc053 100644 --- a/API/DTOs/Scrobbling/MalUserInfoDto.cs +++ b/API/DTOs/Scrobbling/MalUserInfoDto.cs @@ -3,7 +3,7 @@ /// /// Information about a User's MAL connection /// -public class MalUserInfoDto +public sealed record MalUserInfoDto { public required string Username { get; set; } /// diff --git a/API/DTOs/Scrobbling/MediaRecommendationDto.cs b/API/DTOs/Scrobbling/MediaRecommendationDto.cs index c83694b2b..476d77279 100644 --- a/API/DTOs/Scrobbling/MediaRecommendationDto.cs +++ b/API/DTOs/Scrobbling/MediaRecommendationDto.cs @@ -2,8 +2,9 @@ using API.Services.Plus; namespace API.DTOs.Scrobbling; +#nullable enable -public record MediaRecommendationDto +public sealed record MediaRecommendationDto { public int Rating { get; set; } public IEnumerable RecommendationNames { get; set; } = null!; diff --git a/API/DTOs/Scrobbling/PlusSeriesDto.cs b/API/DTOs/Scrobbling/PlusSeriesDto.cs index 552a86575..4d0ef4ea1 100644 --- a/API/DTOs/Scrobbling/PlusSeriesDto.cs +++ b/API/DTOs/Scrobbling/PlusSeriesDto.cs @@ -1,14 +1,22 @@ namespace API.DTOs.Scrobbling; +#nullable enable -public record PlusSeriesDto +/// +/// Represents information about a potential Series for Kavita+ +/// +public sealed record PlusSeriesRequestDto { public int? AniListId { get; set; } public long? MalId { get; set; } public string? GoogleBooksId { get; set; } public string? MangaDexId { get; set; } + /// + /// ComicBookRoundup Id + /// + public int? CbrId { get; set; } public string SeriesName { get; set; } public string? AltSeriesName { get; set; } - public MediaFormat MediaFormat { get; set; } + public PlusMediaFormat MediaFormat { get; set; } /// /// Optional but can help with matching /// diff --git a/API/DTOs/Scrobbling/ScrobbleDto.cs b/API/DTOs/Scrobbling/ScrobbleDto.cs index ca2c2e528..b90441059 100644 --- a/API/DTOs/Scrobbling/ScrobbleDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleDto.cs @@ -22,7 +22,7 @@ public enum ScrobbleEventType /// /// Represents PlusMediaFormat /// -public enum MediaFormat +public enum PlusMediaFormat { [Description("Manga")] Manga = 1, @@ -36,7 +36,7 @@ public enum MediaFormat } -public class ScrobbleDto +public sealed record ScrobbleDto { /// /// User's access token to allow us to talk on their behalf @@ -44,7 +44,7 @@ public class ScrobbleDto public string AniListToken { get; set; } public string SeriesName { get; set; } public string LocalizedSeriesName { get; set; } - public MediaFormat Format { get; set; } + public PlusMediaFormat Format { get; set; } public int? Year { get; set; } /// /// Optional AniListId if present on Kavita's WebLinks diff --git a/API/DTOs/Scrobbling/ScrobbleErrorDto.cs b/API/DTOs/Scrobbling/ScrobbleErrorDto.cs index da85f28f1..7caaad1ca 100644 --- a/API/DTOs/Scrobbling/ScrobbleErrorDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleErrorDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Scrobbling; -public class ScrobbleErrorDto +public sealed record ScrobbleErrorDto { /// /// Developer defined string diff --git a/API/DTOs/Scrobbling/ScrobbleEventDto.cs b/API/DTOs/Scrobbling/ScrobbleEventDto.cs index 298e32180..562d923ff 100644 --- a/API/DTOs/Scrobbling/ScrobbleEventDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleEventDto.cs @@ -1,9 +1,11 @@ using System; namespace API.DTOs.Scrobbling; +#nullable enable -public class ScrobbleEventDto +public sealed record ScrobbleEventDto { + public long Id { get; init; } public string SeriesName { get; set; } public int SeriesId { get; set; } public int LibraryId { get; set; } diff --git a/API/DTOs/Scrobbling/ScrobbleHoldDto.cs b/API/DTOs/Scrobbling/ScrobbleHoldDto.cs index dcfe7726f..3e09e4799 100644 --- a/API/DTOs/Scrobbling/ScrobbleHoldDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleHoldDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Scrobbling; -public class ScrobbleHoldDto +public sealed record ScrobbleHoldDto { public string SeriesName { get; set; } public int SeriesId { get; set; } diff --git a/API/DTOs/Scrobbling/ScrobbleResponseDto.cs b/API/DTOs/Scrobbling/ScrobbleResponseDto.cs index a63e955d7..ad66729d0 100644 --- a/API/DTOs/Scrobbling/ScrobbleResponseDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleResponseDto.cs @@ -4,9 +4,10 @@ /// /// Response from Kavita+ Scrobble API /// -public class ScrobbleResponseDto +public sealed record ScrobbleResponseDto { public bool Successful { get; set; } public string? ErrorMessage { get; set; } + public string? ExtraInformation {get; set;} public int RateLeft { get; set; } } diff --git a/API/DTOs/Search/BookmarkSearchResultDto.cs b/API/DTOs/Search/BookmarkSearchResultDto.cs index 5d53add1f..c11d2a2b8 100644 --- a/API/DTOs/Search/BookmarkSearchResultDto.cs +++ b/API/DTOs/Search/BookmarkSearchResultDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Search; -public class BookmarkSearchResultDto +public sealed record BookmarkSearchResultDto { public int LibraryId { get; set; } public int VolumeId { get; set; } diff --git a/API/DTOs/Search/SearchResultDto.cs b/API/DTOs/Search/SearchResultDto.cs index 6fcae3b5d..c497b55dd 100644 --- a/API/DTOs/Search/SearchResultDto.cs +++ b/API/DTOs/Search/SearchResultDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Search; -public class SearchResultDto +public sealed record SearchResultDto { public int SeriesId { get; init; } public string Name { get; init; } = default!; diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs index f7a622664..11c4bdc08 100644 --- a/API/DTOs/Search/SearchResultGroupDto.cs +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -2,6 +2,7 @@ using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.Reader; using API.DTOs.ReadingLists; @@ -10,7 +11,7 @@ namespace API.DTOs.Search; /// /// Represents all Search results for a query /// -public class SearchResultGroupDto +public sealed record SearchResultGroupDto { public IEnumerable Libraries { get; set; } = default!; public IEnumerable Series { get; set; } = default!; diff --git a/API/DTOs/SeriesByIdsDto.cs b/API/DTOs/SeriesByIdsDto.cs index 12e13d96f..cb4c52b1e 100644 --- a/API/DTOs/SeriesByIdsDto.cs +++ b/API/DTOs/SeriesByIdsDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs; -public class SeriesByIdsDto +public sealed record SeriesByIdsDto { public int[] SeriesIds { get; init; } = default!; } diff --git a/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs b/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs index 0f1a8eb4b..1bea81c84 100644 --- a/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs +++ b/API/DTOs/SeriesDetail/NextExpectedChapterDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SeriesDetail; -public class NextExpectedChapterDto +public sealed record NextExpectedChapterDto { public float ChapterNumber { get; set; } public float VolumeNumber { get; set; } diff --git a/API/DTOs/SeriesDetail/RelatedSeriesDto.cs b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs index 29b9eb263..a186dc295 100644 --- a/API/DTOs/SeriesDetail/RelatedSeriesDto.cs +++ b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SeriesDetail; -public class RelatedSeriesDto +public sealed record RelatedSeriesDto { /// /// The parent relationship Series diff --git a/API/DTOs/SeriesDetail/SeriesDetailDto.cs b/API/DTOs/SeriesDetail/SeriesDetailDto.cs index 65d657c67..c4f15552d 100644 --- a/API/DTOs/SeriesDetail/SeriesDetailDto.cs +++ b/API/DTOs/SeriesDetail/SeriesDetailDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.SeriesDetail; /// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout. /// This is subject to change, do not rely on this Data model. /// -public class SeriesDetailDto +public sealed record SeriesDetailDto { /// /// Specials for the Series. These will have their title and range cleaned to remove the special marker and prepare diff --git a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs index 59ce47bf6..95f5f39bd 100644 --- a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs +++ b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs @@ -1,15 +1,18 @@ using System.Collections.Generic; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Recommendation; namespace API.DTOs.SeriesDetail; +#nullable enable /// /// All the data from Kavita+ for Series Detail /// /// This is what the UI sees, not what the API sends back -public class SeriesDetailPlusDto +public sealed record SeriesDetailPlusDto { public RecommendationDto? Recommendations { get; set; } public IEnumerable Reviews { get; set; } public IEnumerable? Ratings { get; set; } + public ExternalSeriesDetailDto? Series { get; set; } } diff --git a/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs index f19ad9ca8..a1bb2057e 100644 --- a/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs +++ b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SeriesDetail; -public class UpdateRelatedSeriesDto +public sealed record UpdateRelatedSeriesDto { public int SeriesId { get; set; } public IList Adaptations { get; set; } = default!; diff --git a/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs b/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs index b25b01672..7af9441c1 100644 --- a/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs +++ b/API/DTOs/SeriesDetail/UpdateUserReviewDto.cs @@ -1,10 +1,10 @@ -using System.ComponentModel.DataAnnotations; - + namespace API.DTOs.SeriesDetail; #nullable enable -public class UpdateUserReviewDto +public sealed record UpdateUserReviewDto { public int SeriesId { get; set; } + public int? ChapterId { get; set; } public string Body { get; set; } } diff --git a/API/DTOs/SeriesDetail/UserReviewDto.cs b/API/DTOs/SeriesDetail/UserReviewDto.cs index 0e080d43f..9e05bbd65 100644 --- a/API/DTOs/SeriesDetail/UserReviewDto.cs +++ b/API/DTOs/SeriesDetail/UserReviewDto.cs @@ -1,4 +1,6 @@ -using API.Services.Plus; +using API.Entities; +using API.Entities.Enums; +using API.Services.Plus; namespace API.DTOs.SeriesDetail; #nullable enable @@ -7,7 +9,7 @@ namespace API.DTOs.SeriesDetail; /// Represents a User Review for a given Series /// /// The user does not need to be a Kavita user -public class UserReviewDto +public sealed record UserReviewDto { /// /// A tagline for the review @@ -26,6 +28,7 @@ public class UserReviewDto /// The series this is for /// public int SeriesId { get; set; } + public int? ChapterId { get; set; } /// /// The library this series belongs in /// @@ -54,4 +57,8 @@ public class UserReviewDto /// If this review is External, which Provider did it come from /// public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita; + /// + /// Source of the Rating + /// + public RatingAuthority Authority { get; set; } = RatingAuthority.User; } diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index 445044eef..8a49d4c05 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -5,14 +5,21 @@ using API.Entities.Interfaces; namespace API.DTOs; #nullable enable -public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage +public sealed record SeriesDto : IHasReadTimeEstimate, IHasCoverImage { + /// public int Id { get; init; } + /// public string? Name { get; init; } + /// public string? OriginalName { get; init; } + /// public string? LocalizedName { get; init; } + /// public string? SortName { get; init; } + /// public int Pages { get; init; } + /// public bool CoverImageLocked { get; set; } /// /// Sum of pages read from linked Volumes. Calculated at API-time. @@ -22,9 +29,7 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage /// DateTime representing last time the series was Read. Calculated at API-time. /// public DateTime LatestReadDate { get; set; } - /// - /// DateTime representing last time a chapter was added to the Series - /// + /// public DateTime LastChapterAdded { get; set; } /// /// Rating from logged in user. Calculated at API-time. @@ -35,17 +40,19 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage /// public bool HasUserRated { get; set; } + /// public MangaFormat Format { get; set; } + /// public DateTime Created { get; set; } - public bool NameLocked { get; set; } + /// public bool SortNameLocked { get; set; } + /// public bool LocalizedNameLocked { get; set; } - /// - /// Total number of words for the series. Only applies to epubs. - /// + /// public long WordCount { get; set; } + /// public int LibraryId { get; set; } public string LibraryName { get; set; } = default!; /// @@ -54,23 +61,25 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage public int MaxHoursToRead { get; set; } /// public float AvgHoursToRead { get; set; } - /// - /// The highest level folder for this Series - /// + /// public string FolderPath { get; set; } = default!; - /// - /// Lowest path (that is under library root) that contains all files for the series. - /// - /// must be used before setting + /// public string? LowestFolderPath { get; set; } - /// - /// The last time the folder for this series was scanned - /// + /// public DateTime LastFolderScanned { get; set; } + #region KavitaPlus + /// + public bool DontMatch { get; set; } + /// + public bool IsBlacklisted { get; set; } + #endregion + /// public string? CoverImage { get; set; } - public string PrimaryColor { get; set; } - public string SecondaryColor { get; set; } + /// + public string? PrimaryColor { get; set; } = string.Empty; + /// + public string? SecondaryColor { get; set; } = string.Empty; public void ResetColorScape() { diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index 3f344dff5..fa745148e 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs; -public class SeriesMetadataDto +public sealed record SeriesMetadataDto { public int Id { get; set; } public string Summary { get; set; } = string.Empty; diff --git a/API/DTOs/Settings/SMTPConfigDto.cs b/API/DTOs/Settings/SMTPConfigDto.cs index 07cc58cb8..c14140062 100644 --- a/API/DTOs/Settings/SMTPConfigDto.cs +++ b/API/DTOs/Settings/SMTPConfigDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Settings; -public class SmtpConfigDto +public sealed record SmtpConfigDto { public string SenderAddress { get; set; } = string.Empty; public string SenderDisplayName { get; set; } = string.Empty; diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 45abcc528..372042250 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -1,10 +1,12 @@ using System; +using System.Text.Json.Serialization; using API.Entities.Enums; using API.Services; namespace API.DTOs.Settings; +#nullable enable -public class ServerSettingDto +public sealed record ServerSettingDto { public string CacheDirectory { get; set; } = default!; @@ -44,6 +46,7 @@ public class ServerSettingDto /// /// Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs. /// + public string InstallId { get; set; } = default!; /// /// The format that should be used when saving media for Kavita diff --git a/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs b/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs index 1b081913d..ae1d927a9 100644 --- a/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs +++ b/API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SideNav; -public class BulkUpdateSideNavStreamVisibilityDto +public sealed record BulkUpdateSideNavStreamVisibilityDto { public required IList Ids { get; set; } public required bool Visibility { get; set; } diff --git a/API/DTOs/SideNav/ExternalSourceDto.cs b/API/DTOs/SideNav/ExternalSourceDto.cs index e9ae03066..382124e8a 100644 --- a/API/DTOs/SideNav/ExternalSourceDto.cs +++ b/API/DTOs/SideNav/ExternalSourceDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.SideNav; -public class ExternalSourceDto +public sealed record ExternalSourceDto { public required int Id { get; set; } = 0; public required string Name { get; set; } diff --git a/API/DTOs/SideNav/SideNavStreamDto.cs b/API/DTOs/SideNav/SideNavStreamDto.cs index 1f3453611..f4c196244 100644 --- a/API/DTOs/SideNav/SideNavStreamDto.cs +++ b/API/DTOs/SideNav/SideNavStreamDto.cs @@ -2,8 +2,9 @@ using API.Entities.Enums; namespace API.DTOs.SideNav; +#nullable enable -public class SideNavStreamDto +public sealed record SideNavStreamDto { public int Id { get; set; } public required string Name { get; set; } diff --git a/API/DTOs/StandaloneChapterDto.cs b/API/DTOs/StandaloneChapterDto.cs index 6d8b5423d..2f4cd2ee1 100644 --- a/API/DTOs/StandaloneChapterDto.cs +++ b/API/DTOs/StandaloneChapterDto.cs @@ -1,6 +1,7 @@ using API.Entities.Enums; namespace API.DTOs; +#nullable enable /// /// Used on Person Profile page diff --git a/API/DTOs/Statistics/Count.cs b/API/DTOs/Statistics/Count.cs index 411b44897..1577e682c 100644 --- a/API/DTOs/Statistics/Count.cs +++ b/API/DTOs/Statistics/Count.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Statistics; -public class StatCount : ICount +public sealed record StatCount : ICount { public T Value { get; set; } = default!; public long Count { get; set; } diff --git a/API/DTOs/Statistics/FileExtensionBreakdownDto.cs b/API/DTOs/Statistics/FileExtensionBreakdownDto.cs index 1f122d992..7a248caef 100644 --- a/API/DTOs/Statistics/FileExtensionBreakdownDto.cs +++ b/API/DTOs/Statistics/FileExtensionBreakdownDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs.Statistics; #nullable enable -public class FileExtensionDto +public sealed record FileExtensionDto { public string? Extension { get; set; } public MangaFormat Format { get; set; } @@ -12,7 +12,7 @@ public class FileExtensionDto public long TotalFiles { get; set; } } -public class FileExtensionBreakdownDto +public sealed record FileExtensionBreakdownDto { /// /// Total bytes for all files diff --git a/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs b/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs deleted file mode 100644 index 9ce44b6fa..000000000 --- a/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace API.DTOs.Statistics; - -public class KavitaPlusMetadataBreakdownDto -{ - /// - /// Total amount of Series - /// - public int TotalSeries { get; set; } - /// - /// Series on the Blacklist (errored or bad match) - /// - public int ErroredSeries { get; set; } - /// - /// Completed so far - /// - public int SeriesCompleted { get; set; } -} diff --git a/API/DTOs/Statistics/PagesReadOnADayCount.cs b/API/DTOs/Statistics/PagesReadOnADayCount.cs index b1a6bb1ea..fc56d9cc0 100644 --- a/API/DTOs/Statistics/PagesReadOnADayCount.cs +++ b/API/DTOs/Statistics/PagesReadOnADayCount.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Statistics; -public class PagesReadOnADayCount : ICount +public sealed record PagesReadOnADayCount : ICount { /// /// The day of the readings diff --git a/API/DTOs/Statistics/ReadHistoryEvent.cs b/API/DTOs/Statistics/ReadHistoryEvent.cs index adb4040ed..5d8262aef 100644 --- a/API/DTOs/Statistics/ReadHistoryEvent.cs +++ b/API/DTOs/Statistics/ReadHistoryEvent.cs @@ -1,11 +1,12 @@ using System; namespace API.DTOs.Statistics; +#nullable enable /// /// Represents a single User's reading event /// -public class ReadHistoryEvent +public sealed record ReadHistoryEvent { public int UserId { get; set; } public required string? UserName { get; set; } = default!; @@ -13,6 +14,7 @@ public class ReadHistoryEvent public int SeriesId { get; set; } public required string SeriesName { get; set; } = default!; public DateTime ReadDate { get; set; } + public DateTime ReadDateUtc { get; set; } public int ChapterId { get; set; } public required float ChapterNumber { get; set; } = default!; } diff --git a/API/DTOs/Statistics/ServerStatisticsDto.cs b/API/DTOs/Statistics/ServerStatisticsDto.cs index 57fd5abce..3d22d9a56 100644 --- a/API/DTOs/Statistics/ServerStatisticsDto.cs +++ b/API/DTOs/Statistics/ServerStatisticsDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs.Statistics; #nullable enable -public class ServerStatisticsDto +public sealed record ServerStatisticsDto { public long ChapterCount { get; set; } public long VolumeCount { get; set; } diff --git a/API/DTOs/Statistics/TopReadsDto.cs b/API/DTOs/Statistics/TopReadsDto.cs index 806360533..d11594dca 100644 --- a/API/DTOs/Statistics/TopReadsDto.cs +++ b/API/DTOs/Statistics/TopReadsDto.cs @@ -1,7 +1,7 @@ namespace API.DTOs.Statistics; #nullable enable -public class TopReadDto +public sealed record TopReadDto { public int UserId { get; set; } public string? Username { get; set; } = default!; diff --git a/API/DTOs/Statistics/UserReadStatistics.cs b/API/DTOs/Statistics/UserReadStatistics.cs index 5e3f5aa5d..5c6935c6e 100644 --- a/API/DTOs/Statistics/UserReadStatistics.cs +++ b/API/DTOs/Statistics/UserReadStatistics.cs @@ -2,8 +2,9 @@ using System.Collections.Generic; namespace API.DTOs.Statistics; +#nullable enable -public class UserReadStatistics +public sealed record UserReadStatistics { /// /// Total number of pages read diff --git a/API/DTOs/Stats/FileExtensionExportDto.cs b/API/DTOs/Stats/FileExtensionExportDto.cs index 6ed554d75..e881960a5 100644 --- a/API/DTOs/Stats/FileExtensionExportDto.cs +++ b/API/DTOs/Stats/FileExtensionExportDto.cs @@ -5,7 +5,7 @@ namespace API.DTOs.Stats; /// /// Excel export for File Extension Report /// -public class FileExtensionExportDto +public sealed record FileExtensionExportDto { [Name("Path")] public string FilePath { get; set; } diff --git a/API/DTOs/Stats/FileFormatDto.cs b/API/DTOs/Stats/FileFormatDto.cs deleted file mode 100644 index 6319bd2a9..000000000 --- a/API/DTOs/Stats/FileFormatDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -using API.Entities.Enums; - -namespace API.DTOs.Stats; - -public class FileFormatDto -{ - /// - /// The extension with the ., in lowercase - /// - public required string Extension { get; set; } - /// - /// Format of extension - /// - public required MangaFormat Format { get; set; } -} diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs deleted file mode 100644 index 41c4c8264..000000000 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.Collections.Generic; -using API.Entities.Enums; - -namespace API.DTOs.Stats; -#nullable enable - -/// -/// Represents information about a Kavita Installation -/// -public class ServerInfoDto -{ - /// - /// Unique Id that represents a unique install - /// - public required string InstallId { get; set; } - public required string Os { get; set; } - /// - /// If the Kavita install is using Docker - /// - public bool IsDocker { get; set; } - /// - /// Version of .NET instance is running - /// - public required string DotnetVersion { get; set; } - /// - /// Version of Kavita - /// - public required string KavitaVersion { get; set; } - /// - /// Number of Cores on the instance - /// - public int NumOfCores { get; set; } - /// - /// The number of libraries on the instance - /// - public int NumberOfLibraries { get; set; } - /// - /// Does any user have bookmarks - /// - public bool HasBookmarks { get; set; } - /// - /// The site theme the install is using - /// - /// Introduced in v0.5.2 - public string? ActiveSiteTheme { get; set; } - /// - /// The reading mode the main user has as a preference - /// - /// Introduced in v0.5.2 - public ReaderMode MangaReaderMode { get; set; } - - /// - /// Number of users on the install - /// - /// Introduced in v0.5.2 - public int NumberOfUsers { get; set; } - - /// - /// Number of collections on the install - /// - /// Introduced in v0.5.2 - public int NumberOfCollections { get; set; } - /// - /// Number of reading lists on the install (Sum of all users) - /// - /// Introduced in v0.5.2 - public int NumberOfReadingLists { get; set; } - /// - /// Is OPDS enabled - /// - /// Introduced in v0.5.2 - public bool OPDSEnabled { get; set; } - /// - /// Total number of files in the instance - /// - /// Introduced in v0.5.2 - public int TotalFiles { get; set; } - /// - /// Total number of Genres in the instance - /// - /// Introduced in v0.5.4 - public int TotalGenres { get; set; } - /// - /// Total number of People in the instance - /// - /// Introduced in v0.5.4 - public int TotalPeople { get; set; } - /// - /// Number of users on this instance using Card Layout - /// - /// Introduced in v0.5.4 - public int UsersOnCardLayout { get; set; } - /// - /// Number of users on this instance using List Layout - /// - /// Introduced in v0.5.4 - public int UsersOnListLayout { get; set; } - /// - /// Max number of Series for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxSeriesInALibrary { get; set; } - /// - /// Max number of Volumes for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxVolumesInASeries { get; set; } - /// - /// Max number of Chapters for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxChaptersInASeries { get; set; } - /// - /// Does this instance have relationships setup between series - /// - /// Introduced in v0.5.4 - public bool UsingSeriesRelationships { get; set; } - /// - /// A list of background colors set on the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable MangaReaderBackgroundColors { get; set; } - /// - /// A list of Page Split defaults being used on the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable MangaReaderPageSplittingModes { get; set; } - /// - /// A list of Layout Mode defaults being used on the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable MangaReaderLayoutModes { get; set; } - /// - /// A list of file formats existing in the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable FileFormats { get; set; } - /// - /// If there is at least one user that is using an age restricted profile on the instance - /// - /// Introduced in v0.6.0 - public bool UsingRestrictedProfiles { get; set; } - /// - /// Number of users using the Emulate Comic Book setting - /// - /// Introduced in v0.7.0 - public int UsersWithEmulateComicBook { get; set; } - /// - /// Percent (0.0-1.0) of libraries with folder watching enabled - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesWithFolderWatchingEnabled { get; set; } - /// - /// Percent (0.0-1.0) of libraries included in Search - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesIncludedInSearch { get; set; } - /// - /// Percent (0.0-1.0) of libraries included in Recommended - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesIncludedInRecommended { get; set; } - /// - /// Percent (0.0-1.0) of libraries included in Dashboard - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesIncludedInDashboard { get; set; } - /// - /// Total reading hours of all users - /// - /// Introduced in v0.7.0 - public long TotalReadingHours { get; set; } - /// - /// The encoding the server is using to save media - /// - /// Added in v0.7.3 - public EncodeFormat EncodeMediaAs { get; set; } - /// - /// The last user reading progress on the server (in UTC) - /// - /// Added in v0.7.4 - public DateTime LastReadTime { get; set; } -} diff --git a/API/DTOs/Stats/ServerInfoSlimDto.cs b/API/DTOs/Stats/ServerInfoSlimDto.cs index ef44bb408..f1abb2e1d 100644 --- a/API/DTOs/Stats/ServerInfoSlimDto.cs +++ b/API/DTOs/Stats/ServerInfoSlimDto.cs @@ -1,11 +1,12 @@ using System; namespace API.DTOs.Stats; +#nullable enable /// /// This is just for the Server tab on UI /// -public class ServerInfoSlimDto +public sealed record ServerInfoSlimDto { /// /// Unique Id that represents a unique install diff --git a/API/DTOs/Stats/V3/LibraryStatV3.cs b/API/DTOs/Stats/V3/LibraryStatV3.cs new file mode 100644 index 000000000..33ac86d2b --- /dev/null +++ b/API/DTOs/Stats/V3/LibraryStatV3.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.DTOs.Stats.V3; + +public sealed record LibraryStatV3 +{ + public bool IncludeInDashboard { get; set; } + public bool IncludeInSearch { get; set; } + public bool UsingFolderWatching { get; set; } + /// + /// Are any exclude patterns setup + /// + public bool UsingExcludePatterns { get; set; } + /// + /// Will this library create collections from ComicInfo + /// + public bool CreateCollectionsFromMetadata { get; set; } + /// + /// Will this library create reading lists from ComicInfo + /// + public bool CreateReadingListsFromMetadata { get; set; } + /// + /// Type of the Library + /// + public LibraryType LibraryType { get; set; } + public ICollection FileTypes { get; set; } + /// + /// Last time library was fully scanned + /// + public DateTime LastScanned { get; set; } + /// + /// Number of folders the library has + /// + public int NumberOfFolders { get; set; } + + +} diff --git a/API/DTOs/Stats/V3/RelationshipStatV3.cs b/API/DTOs/Stats/V3/RelationshipStatV3.cs new file mode 100644 index 000000000..37b63cb9a --- /dev/null +++ b/API/DTOs/Stats/V3/RelationshipStatV3.cs @@ -0,0 +1,12 @@ +using API.Entities.Enums; + +namespace API.DTOs.Stats.V3; + +/// +/// KavitaStats - Information about Series Relationships +/// +public sealed record RelationshipStatV3 +{ + public int Count { get; set; } + public RelationKind Relationship { get; set; } +} diff --git a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs new file mode 100644 index 000000000..8ed3079f5 --- /dev/null +++ b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.DTOs.Stats.V3; + +/// +/// Represents information about a Kavita Installation for Kavita Stats v3 API +/// +public sealed record ServerInfoV3Dto +{ + /// + /// Unique Id that represents a unique install + /// + public required string InstallId { get; set; } + public required string Os { get; set; } + /// + /// If the Kavita install is using Docker + /// + public bool IsDocker { get; set; } + /// + /// Version of .NET instance is running + /// + public required string DotnetVersion { get; set; } + /// + /// Version of Kavita + /// + public required string KavitaVersion { get; set; } + /// + /// Version of Kavita on Installation + /// + public required string InitialKavitaVersion { get; set; } + /// + /// Date of first Installation + /// + public DateTime InitialInstallDate { get; set; } + /// + /// Number of Cores on the instance + /// + public int NumOfCores { get; set; } + /// + /// OS locale on the instance + /// + public string OsLocale { get; set; } + /// + /// Milliseconds to open a random archive (zip/cbz) for reading + /// + public long TimeToOpeCbzMs { get; set; } + /// + /// Number of pages for said archive (zip/cbz) + /// + public long TimeToOpenCbzPages { get; set; } + /// + /// Milliseconds to get a response from KavitaStats API + /// + /// This pings a health check and does not capture any IP Information + public long TimeToPingKavitaStatsApi { get; set; } + /// + /// If using the downloading metadata feature + /// + /// Kavita+ Only + public bool MatchedMetadataEnabled { get; set; } + + + + #region Media + /// + /// Number of collections on the install + /// + public int NumberOfCollections { get; set; } + /// + /// Number of reading lists on the install (Sum of all users) + /// + public int NumberOfReadingLists { get; set; } + /// + /// Total number of files in the instance + /// + public int TotalFiles { get; set; } + /// + /// Total number of Genres in the instance + /// + public int TotalGenres { get; set; } + /// + /// Total number of Series in the instance + /// + public int TotalSeries { get; set; } + /// + /// Total number of Libraries in the instance + /// + public int TotalLibraries { get; set; } + /// + /// Total number of People in the instance + /// + public int TotalPeople { get; set; } + /// + /// Max number of Series for any library on the instance + /// + public int MaxSeriesInALibrary { get; set; } + /// + /// Max number of Volumes for any library on the instance + /// + public int MaxVolumesInASeries { get; set; } + /// + /// Max number of Chapters for any library on the instance + /// + public int MaxChaptersInASeries { get; set; } + /// + /// Everything about the Libraries on the instance + /// + public IList Libraries { get; set; } + /// + /// Everything around Series Relationships between series + /// + public IList Relationships { get; set; } + #endregion + + #region Server + /// + /// Is OPDS enabled + /// + public bool OpdsEnabled { get; set; } + /// + /// The encoding the server is using to save media + /// + public EncodeFormat EncodeMediaAs { get; set; } + /// + /// The last user reading progress on the server (in UTC) + /// + public DateTime LastReadTime { get; set; } + /// + /// Is this server using Kavita+ + /// + public bool ActiveKavitaPlusSubscription { get; set; } + #endregion + + #region Users + /// + /// If there is at least one user that is using an age restricted profile on the instance + /// + /// Introduced in v0.6.0 + public bool UsingRestrictedProfiles { get; set; } + + public IList Users { get; set; } + + #endregion +} diff --git a/API/DTOs/Stats/V3/UserStatV3.cs b/API/DTOs/Stats/V3/UserStatV3.cs new file mode 100644 index 000000000..450a2e409 --- /dev/null +++ b/API/DTOs/Stats/V3/UserStatV3.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using API.Data.Misc; +using API.Entities.Enums.Device; + +namespace API.DTOs.Stats.V3; + +public sealed record UserStatV3 +{ + public AgeRestriction AgeRestriction { get; set; } + /// + /// The last reading progress on the server (in UTC) + /// + public DateTime LastReadTime { get; set; } + /// + /// The last login on the server (in UTC) + /// + public DateTime LastLogin { get; set; } + /// + /// Has the user gone through email confirmation + /// + public bool IsEmailConfirmed { get; set; } + /// + /// Is the Email a valid address + /// + public bool HasValidEmail { get; set; } + /// + /// Float between 0-1 to showcase how much of the libraries a user has access to + /// + public float PercentageOfLibrariesHasAccess { get; set; } + /// + /// Number of reading lists this user created + /// + public int ReadingListsCreatedCount { get; set; } + /// + /// Number of collections this user created + /// + public int CollectionsCreatedCount { get; set; } + /// + /// Number of series in want to read for this user + /// + public int WantToReadSeriesCount { get; set; } + /// + /// Active locale for the user + /// + public string Locale { get; set; } + /// + /// Active Theme (name) + /// + public string ActiveTheme { get; set; } + /// + /// Number of series with Bookmarks created + /// + public int SeriesBookmarksCreatedCount { get; set; } + /// + /// Kavita+ only - Has an AniList Token set + /// + public bool HasAniListToken { get; set; } + /// + /// Kavita+ only - Has a MAL Token set + /// + public bool HasMALToken { get; set; } + /// + /// Number of Smart Filters a user has created + /// + public int SmartFilterCreatedCount { get; set; } + /// + /// Is the user sharing reviews + /// + public bool IsSharingReviews { get; set; } + /// + /// The number of devices setup and their platforms + /// + public ICollection DevicePlatforms { get; set; } + /// + /// Roles for this user + /// + public ICollection Roles { get; set; } + + +} diff --git a/API/DTOs/System/DirectoryDto.cs b/API/DTOs/System/DirectoryDto.cs index e6e94f4e4..3b1408f7f 100644 --- a/API/DTOs/System/DirectoryDto.cs +++ b/API/DTOs/System/DirectoryDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.System; -public class DirectoryDto +public sealed record DirectoryDto { /// /// Name of the directory diff --git a/API/DTOs/TachiyomiChapterDto.cs b/API/DTOs/TachiyomiChapterDto.cs index 03e242dfa..ecdd5115c 100644 --- a/API/DTOs/TachiyomiChapterDto.cs +++ b/API/DTOs/TachiyomiChapterDto.cs @@ -1,4 +1,5 @@ namespace API.DTOs; +#nullable enable /// /// This is explicitly for Tachiyomi. Number field was removed in v0.8.0, but Tachiyomi needs it for the hacks. diff --git a/API/DTOs/Theme/ColorScapeDto.cs b/API/DTOs/Theme/ColorScapeDto.cs index 066e87d84..2ebd96e2b 100644 --- a/API/DTOs/Theme/ColorScapeDto.cs +++ b/API/DTOs/Theme/ColorScapeDto.cs @@ -4,7 +4,7 @@ /// /// A set of colors for the color scape system in the UI /// -public class ColorScapeDto +public sealed record ColorScapeDto { public string? Primary { get; set; } public string? Secondary { get; set; } diff --git a/API/DTOs/Theme/DownloadableSiteThemeDto.cs b/API/DTOs/Theme/DownloadableSiteThemeDto.cs index dbcedfe61..b27263d92 100644 --- a/API/DTOs/Theme/DownloadableSiteThemeDto.cs +++ b/API/DTOs/Theme/DownloadableSiteThemeDto.cs @@ -4,7 +4,7 @@ using System.Collections.Generic; namespace API.DTOs.Theme; -public class DownloadableSiteThemeDto +public sealed record DownloadableSiteThemeDto { /// /// Theme Name diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs index eb2a14904..7ae8369e9 100644 --- a/API/DTOs/Theme/SiteThemeDto.cs +++ b/API/DTOs/Theme/SiteThemeDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.Theme; /// /// Represents a set of css overrides the user can upload to Kavita and will load into webui /// -public class SiteThemeDto +public sealed record SiteThemeDto { public int Id { get; set; } /// diff --git a/API/DTOs/Theme/UpdateDefaultThemeDto.cs b/API/DTOs/Theme/UpdateDefaultThemeDto.cs index 0f2b129f3..aac0858c3 100644 --- a/API/DTOs/Theme/UpdateDefaultThemeDto.cs +++ b/API/DTOs/Theme/UpdateDefaultThemeDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Theme; -public class UpdateDefaultThemeDto +public sealed record UpdateDefaultThemeDto { public int ThemeId { get; set; } } diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index a83aa072b..b535684f0 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -1,9 +1,12 @@ -namespace API.DTOs.Update; +using System.Collections.Generic; +using System.Runtime.InteropServices.JavaScript; + +namespace API.DTOs.Update; /// /// Update Notification denoting a new release available for user to update to /// -public class UpdateNotificationDto +public sealed record UpdateNotificationDto { /// /// Current installed Version @@ -21,11 +24,11 @@ public class UpdateNotificationDto /// /// Title of the release /// - public required string UpdateTitle { get; init; } + public required string UpdateTitle { get; set; } /// /// Github Url /// - public required string UpdateUrl { get; init; } + public required string UpdateUrl { get; set; } /// /// If this install is within Docker /// @@ -37,7 +40,7 @@ public class UpdateNotificationDto /// /// Date of the publish /// - public required string PublishDate { get; init; } + public required string PublishDate { get; set; } /// /// Is the server on a nightly within this release /// @@ -50,4 +53,18 @@ public class UpdateNotificationDto /// Is the server on this version /// public bool IsReleaseEqual { get; set; } + + public IList Added { get; set; } + public IList Removed { get; set; } + public IList Changed { get; set; } + public IList Fixed { get; set; } + public IList Theme { get; set; } + public IList Developer { get; set; } + public IList Api { get; set; } + public IList FeatureRequests { get; set; } + public IList KnownIssues { get; set; } + /// + /// The part above the changelog part + /// + public string BlogPart { get; set; } } diff --git a/API/DTOs/UpdateChapterDto.cs b/API/DTOs/UpdateChapterDto.cs index 2ca0a12a9..9ead8adc8 100644 --- a/API/DTOs/UpdateChapterDto.cs +++ b/API/DTOs/UpdateChapterDto.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs; -public class UpdateChapterDto +public sealed record UpdateChapterDto { public int Id { get; init; } public string Summary { get; set; } = string.Empty; diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index 465782bd1..d7f314208 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -4,7 +4,7 @@ using API.Entities.Enums; namespace API.DTOs; -public class UpdateLibraryDto +public sealed record UpdateLibraryDto { [Required] public int Id { get; init; } @@ -26,6 +26,12 @@ public class UpdateLibraryDto public bool ManageReadingLists { get; init; } [Required] public bool AllowScrobbling { get; init; } + [Required] + public bool AllowMetadataMatching { get; init; } + [Required] + public bool EnableMetadata { get; init; } + [Required] + public bool RemovePrefixForSortName { get; init; } /// /// What types of files to allow the scanner to pickup /// diff --git a/API/DTOs/UpdateLibraryForUserDto.cs b/API/DTOs/UpdateLibraryForUserDto.cs index c90b697e2..4ce8d0df8 100644 --- a/API/DTOs/UpdateLibraryForUserDto.cs +++ b/API/DTOs/UpdateLibraryForUserDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public class UpdateLibraryForUserDto +public sealed record UpdateLibraryForUserDto { public required string Username { get; init; } public required IEnumerable SelectedLibraries { get; init; } = new List(); diff --git a/API/DTOs/UpdateRBSDto.cs b/API/DTOs/UpdateRBSDto.cs index a7e0c3fc9..fa8bb78f9 100644 --- a/API/DTOs/UpdateRBSDto.cs +++ b/API/DTOs/UpdateRBSDto.cs @@ -3,7 +3,7 @@ namespace API.DTOs; #nullable enable -public class UpdateRbsDto +public sealed record UpdateRbsDto { public required string Username { get; init; } public IList? Roles { get; init; } diff --git a/API/DTOs/UpdateSeriesRatingDto.cs b/API/DTOs/UpdateRatingDto.cs similarity index 58% rename from API/DTOs/UpdateSeriesRatingDto.cs rename to API/DTOs/UpdateRatingDto.cs index 5dafa35af..472a94fe9 100644 --- a/API/DTOs/UpdateSeriesRatingDto.cs +++ b/API/DTOs/UpdateRatingDto.cs @@ -1,7 +1,8 @@ namespace API.DTOs; -public class UpdateSeriesRatingDto +public sealed record UpdateRatingDto { public int SeriesId { get; init; } + public int? ChapterId { get; init; } public float UserRating { get; init; } } diff --git a/API/DTOs/UpdateSeriesDto.cs b/API/DTOs/UpdateSeriesDto.cs index 52826f9d1..a4a9baf8c 100644 --- a/API/DTOs/UpdateSeriesDto.cs +++ b/API/DTOs/UpdateSeriesDto.cs @@ -1,6 +1,7 @@ namespace API.DTOs; +#nullable enable -public class UpdateSeriesDto +public sealed record UpdateSeriesDto { public int Id { get; init; } public string? LocalizedName { get; init; } diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs index 719a9459a..5225f5486 100644 --- a/API/DTOs/UpdateSeriesMetadataDto.cs +++ b/API/DTOs/UpdateSeriesMetadataDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs; -public class UpdateSeriesMetadataDto +public sealed record UpdateSeriesMetadataDto { - public SeriesMetadataDto SeriesMetadata { get; set; } = default!; + public SeriesMetadataDto SeriesMetadata { get; set; } = null!; } diff --git a/API/DTOs/Uploads/UploadFileDto.cs b/API/DTOs/Uploads/UploadFileDto.cs index 72fe7da9b..8d5cdf4cb 100644 --- a/API/DTOs/Uploads/UploadFileDto.cs +++ b/API/DTOs/Uploads/UploadFileDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Uploads; -public class UploadFileDto +public sealed record UploadFileDto { /// /// Id of the Entity diff --git a/API/DTOs/Uploads/UploadUrlDto.cs b/API/DTOs/Uploads/UploadUrlDto.cs index f2699befd..3f4e625c3 100644 --- a/API/DTOs/Uploads/UploadUrlDto.cs +++ b/API/DTOs/Uploads/UploadUrlDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs.Uploads; -public class UploadUrlDto +public sealed record UploadUrlDto { /// /// External url diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 7d54102da..88dc97a5d 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -1,10 +1,11 @@  +using System; using API.DTOs.Account; namespace API.DTOs; #nullable enable -public class UserDto +public sealed record UserDto { public string Username { get; init; } = null!; public string Email { get; init; } = null!; diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index d1cddf280..46f42306e 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -5,100 +5,10 @@ using API.Entities.Enums; using API.Entities.Enums.UserPreferences; namespace API.DTOs; +#nullable enable -public class UserPreferencesDto +public sealed record UserPreferencesDto { - /// - /// Manga Reader Option: What direction should the next/prev page buttons go - /// - [Required] - public ReadingDirection ReadingDirection { get; set; } - /// - /// Manga Reader Option: How should the image be scaled to screen - /// - [Required] - public ScalingOption ScalingOption { get; set; } - /// - /// Manga Reader Option: Which side of a split image should we show first - /// - [Required] - public PageSplitOption PageSplitOption { get; set; } - /// - /// Manga Reader Option: How the manga reader should perform paging or reading of the file - /// - /// Webtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging - /// by clicking top/bottom sides of reader. - /// - /// - [Required] - public ReaderMode ReaderMode { get; set; } - /// - /// Manga Reader Option: How many pages to display in the reader at once - /// - [Required] - public LayoutMode LayoutMode { get; set; } - /// - /// Manga Reader Option: Emulate a book by applying a shadow effect on the pages - /// - [Required] - public bool EmulateBook { get; set; } - /// - /// Manga Reader Option: Background color of the reader - /// - [Required] - public string BackgroundColor { get; set; } = "#000000"; - /// - /// Manga Reader Option: Should swiping trigger pagination - /// - [Required] - public bool SwipeToPaginate { get; set; } - /// - /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction - /// - [Required] - public bool AutoCloseMenu { get; set; } - /// - /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change - /// - [Required] - public bool ShowScreenHints { get; set; } = true; - /// - /// Book Reader Option: Override extra Margin - /// - [Required] - public int BookReaderMargin { get; set; } - /// - /// Book Reader Option: Override line-height - /// - [Required] - public int BookReaderLineSpacing { get; set; } - /// - /// Book Reader Option: Override font size - /// - [Required] - public int BookReaderFontSize { get; set; } - /// - /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override - /// - [Required] - public string BookReaderFontFamily { get; set; } = null!; - - /// - /// Book Reader Option: Allows tapping on side of screens to paginate - /// - [Required] - public bool BookReaderTapToPaginate { get; set; } - /// - /// Book Reader Option: What direction should the next/prev page buttons go - /// - [Required] - public ReadingDirection BookReaderReadingDirection { get; set; } - - /// - /// Book Reader Option: What writing style should be used, horizontal or vertical. - /// - [Required] - public WritingStyle BookReaderWritingStyle { get; set; } /// /// UI Site Global Setting: The UI theme the user should use. @@ -107,73 +17,28 @@ public class UserPreferencesDto [Required] public SiteThemeDto? Theme { get; set; } - [Required] public string BookReaderThemeName { get; set; } = null!; - [Required] - public BookPageLayoutMode BookReaderLayoutMode { get; set; } - /// - /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. - /// - /// Defaults to false - [Required] - public bool BookReaderImmersiveMode { get; set; } = false; - /// - /// Global Site Option: If the UI should layout items as Cards or List items - /// - /// Defaults to Cards - [Required] public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards; - /// - /// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already - /// - /// Defaults to false + /// [Required] public bool BlurUnreadSummaries { get; set; } = false; - /// - /// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB. - /// + /// [Required] public bool PromptForDownloadSize { get; set; } = true; - /// - /// UI Site Global Setting: Should Kavita disable CSS transitions - /// + /// [Required] public bool NoTransitions { get; set; } = false; - /// - /// When showing series, only parent series or series with no relationships will be returned - /// + /// [Required] public bool CollapseSeriesRelationships { get; set; } = false; - /// - /// UI Site Global Setting: Should series reviews be shared with all users in the server - /// + /// [Required] public bool ShareReviews { get; set; } = false; - /// - /// UI Site Global Setting: The language locale that should be used for the user - /// + /// [Required] public string Locale { get; set; } - /// - /// PDF Reader: Theme of the Reader - /// - [Required] - public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; - /// - /// PDF Reader: Scroll mode of the reader - /// - [Required] - public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; - /// - /// PDF Reader: Layout Mode of the reader - /// - [Required] - public PdfLayoutMode PdfLayoutMode { get; set; } = PdfLayoutMode.Multiple; - /// - /// PDF Reader: Spread Mode of the reader - /// - [Required] - public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; - - + /// + public bool AniListScrobblingEnabled { get; set; } + /// + public bool WantToReadSync { get; set; } } diff --git a/API/DTOs/UserReadingProfileDto.cs b/API/DTOs/UserReadingProfileDto.cs new file mode 100644 index 000000000..24dbf1c34 --- /dev/null +++ b/API/DTOs/UserReadingProfileDto.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Enums.UserPreferences; + +namespace API.DTOs; + +public sealed record UserReadingProfileDto +{ + + public int Id { get; set; } + public int UserId { get; init; } + + public string Name { get; init; } + public ReadingProfileKind Kind { get; init; } + + #region MangaReader + + /// + [Required] + public ReadingDirection ReadingDirection { get; set; } + + /// + [Required] + public ScalingOption ScalingOption { get; set; } + + /// + [Required] + public PageSplitOption PageSplitOption { get; set; } + + /// + [Required] + public ReaderMode ReaderMode { get; set; } + + /// + [Required] + public bool AutoCloseMenu { get; set; } + + /// + [Required] + public bool ShowScreenHints { get; set; } = true; + + /// + [Required] + public bool EmulateBook { get; set; } + + /// + [Required] + public LayoutMode LayoutMode { get; set; } + + /// + [Required] + public string BackgroundColor { get; set; } = "#000000"; + + /// + [Required] + public bool SwipeToPaginate { get; set; } + + /// + [Required] + public bool AllowAutomaticWebtoonReaderDetection { get; set; } + + /// + public int? WidthOverride { get; set; } + + /// + public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never; + + #endregion + + #region EpubReader + + /// + [Required] + public int BookReaderMargin { get; set; } + + /// + [Required] + public int BookReaderLineSpacing { get; set; } + + /// + [Required] + public int BookReaderFontSize { get; set; } + + /// + [Required] + public string BookReaderFontFamily { get; set; } = null!; + + /// + [Required] + public bool BookReaderTapToPaginate { get; set; } + + /// + [Required] + public ReadingDirection BookReaderReadingDirection { get; set; } + + /// + [Required] + public WritingStyle BookReaderWritingStyle { get; set; } + + /// + [Required] + public string BookReaderThemeName { get; set; } = null!; + + /// + [Required] + public BookPageLayoutMode BookReaderLayoutMode { get; set; } + + /// + [Required] + public bool BookReaderImmersiveMode { get; set; } = false; + + #endregion + + #region PdfReader + + /// + [Required] + public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; + + /// + [Required] + public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; + + /// + [Required] + public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; + + #endregion + +} diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index f27cd251d..fffccea59 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -1,5 +1,4 @@ - -using System; +using System; using System.Collections.Generic; using API.Entities; using API.Entities.Interfaces; @@ -8,14 +7,15 @@ using API.Services.Tasks.Scanner.Parser; namespace API.DTOs; -public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage +public sealed record VolumeDto : IHasReadTimeEstimate, IHasCoverImage { + /// public int Id { get; set; } - /// + /// public float MinNumber { get; set; } - /// + /// public float MaxNumber { get; set; } - /// + /// public string Name { get; set; } = default!; /// /// This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14 @@ -24,17 +24,21 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage public int Number { get; set; } public int Pages { get; set; } public int PagesRead { get; set; } + /// public DateTime LastModifiedUtc { get; set; } + /// public DateTime CreatedUtc { get; set; } /// /// When chapter was created in local server time /// /// This is required for Tachiyomi Extension + /// public DateTime Created { get; set; } /// /// When chapter was last modified in local server time /// /// This is required for Tachiyomi Extension + /// public DateTime LastModified { get; set; } public int SeriesId { get; set; } public ICollection Chapters { get; set; } = new List(); @@ -56,7 +60,7 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage } /// - /// Does this volume hold only specials? + /// Does this volume hold only specials /// /// public bool IsSpecial() @@ -64,10 +68,14 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage return MinNumber.Is(Parser.SpecialVolumeNumber); } + /// public string CoverImage { get; set; } + /// private bool CoverImageLocked { get; set; } - public string PrimaryColor { get; set; } - public string SecondaryColor { get; set; } + /// + public string? PrimaryColor { get; set; } = string.Empty; + /// + public string? SecondaryColor { get; set; } = string.Empty; public void ResetColorScape() { diff --git a/API/DTOs/WantToRead/UpdateWantToReadDto.cs b/API/DTOs/WantToRead/UpdateWantToReadDto.cs index f1b38cea2..a5be26857 100644 --- a/API/DTOs/WantToRead/UpdateWantToReadDto.cs +++ b/API/DTOs/WantToRead/UpdateWantToReadDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.WantToRead; /// /// A list of Series to pass when working with Want To Read APIs /// -public class UpdateWantToReadDto +public sealed record UpdateWantToReadDto { /// /// List of Series Ids that will be Added/Removed diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 21b7c26c8..7d529b1da 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -1,12 +1,17 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; +using API.Entities.History; using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Entities.Scrobble; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; @@ -36,12 +41,13 @@ public sealed class DataContext : IdentityDbContext ServerSetting { get; set; } = null!; public DbSet AppUserPreferences { get; set; } = null!; public DbSet SeriesMetadata { get; set; } = null!; - [Obsolete] + [Obsolete("Use AppUserCollection")] public DbSet CollectionTag { get; set; } = null!; public DbSet AppUserBookmark { get; set; } = null!; public DbSet ReadingList { get; set; } = null!; public DbSet ReadingListItem { get; set; } = null!; public DbSet Person { get; set; } = null!; + public DbSet PersonAlias { get; set; } = null!; public DbSet Genre { get; set; } = null!; public DbSet Tag { get; set; } = null!; public DbSet SiteTheme { get; set; } = null!; @@ -64,11 +70,16 @@ public sealed class DataContext : IdentityDbContext ExternalSeriesMetadata { get; set; } = null!; public DbSet ExternalRecommendation { get; set; } = null!; public DbSet ManualMigrationHistory { get; set; } = null!; + [Obsolete("Use IsBlacklisted field on Series")] public DbSet SeriesBlacklist { get; set; } = null!; public DbSet AppUserCollection { get; set; } = null!; public DbSet ChapterPeople { get; set; } = null!; public DbSet SeriesMetadataPeople { get; set; } = null!; - + public DbSet EmailHistory { get; set; } = null!; + public DbSet MetadataSettings { get; set; } = null!; + public DbSet MetadataFieldMapping { get; set; } = null!; + public DbSet AppUserChapterRating { get; set; } = null!; + public DbSet AppUserReadingProfiles { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { @@ -118,10 +129,25 @@ public sealed class DataContext : IdentityDbContext b.Locale) .IsRequired(true) .HasDefaultValue("en"); + builder.Entity() + .Property(b => b.AniListScrobblingEnabled) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.WantToReadSync) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.AllowAutomaticWebtoonReaderDetection) + .HasDefaultValue(true); builder.Entity() .Property(b => b.AllowScrobbling) .HasDefaultValue(true); + builder.Entity() + .Property(b => b.AllowMetadataMatching) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.EnableMetadata) + .HasDefaultValue(true); builder.Entity() .Property(b => b.WebLinks) @@ -187,6 +213,93 @@ public sealed class DataContext : IdentityDbContext p.SeriesMetadataPeople) .HasForeignKey(smp => smp.PersonId) .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.OrderWeight) + .HasDefaultValue(0); + + builder.Entity() + .Property(x => x.AgeRatingMappings) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new Dictionary() + ); + + // Ensure blacklist is stored as a JSON array + builder.Entity() + .Property(x => x.Blacklist) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() + ); + builder.Entity() + .Property(x => x.Whitelist) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() + ); + builder.Entity() + .Property(x => x.Overrides) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() + ); + + // Configure one-to-many relationship + builder.Entity() + .HasMany(x => x.FieldMappings) + .WithOne(x => x.MetadataSettings) + .HasForeignKey(x => x.MetadataSettingsId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.Enabled) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.EnableCoverImage) + .HasDefaultValue(true); + + builder.Entity() + .Property(b => b.BookThemeName) + .HasDefaultValue("Dark"); + builder.Entity() + .Property(b => b.BackgroundColor) + .HasDefaultValue("#000000"); + builder.Entity() + .Property(b => b.BookReaderWritingStyle) + .HasDefaultValue(WritingStyle.Horizontal); + builder.Entity() + .Property(b => b.AllowAutomaticWebtoonReaderDetection) + .HasDefaultValue(true); + + builder.Entity() + .Property(rp => rp.LibraryIds) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasColumnType("TEXT"); + builder.Entity() + .Property(rp => rp.SeriesIds) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasColumnType("TEXT"); + + builder.Entity() + .Property(sm => sm.KPlusOverrides) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? + new List()) + .HasColumnType("TEXT") + .HasDefaultValue(new List()); + builder.Entity() + .Property(sm => sm.KPlusOverrides) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasColumnType("TEXT") + .HasDefaultValue(new List()); } #nullable enable diff --git a/API/Data/ManualMigrations/MigrateLibrariesToHaveAllFileTypes.cs b/API/Data/ManualMigrations/v0.7.11/MigrateLibrariesToHaveAllFileTypes.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateLibrariesToHaveAllFileTypes.cs rename to API/Data/ManualMigrations/v0.7.11/MigrateLibrariesToHaveAllFileTypes.cs diff --git a/API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs b/API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs similarity index 99% rename from API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs rename to API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs index e684ef6a0..d36859e69 100644 --- a/API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs +++ b/API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using API.DTOs.Filtering.v2; using API.Entities; +using API.Entities.History; using API.Helpers; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs b/API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs index 9eff55bc1..89485fd71 100644 --- a/API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/MigrateEmailTemplates.cs b/API/Data/ManualMigrations/v0.7.14/MigrateEmailTemplates.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateEmailTemplates.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateEmailTemplates.cs diff --git a/API/Data/ManualMigrations/MigrateManualHistory.cs b/API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateManualHistory.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs index b9ba1263c..eaf63c41c 100644 --- a/API/Data/ManualMigrations/MigrateManualHistory.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/MigrateVolumeLookupName.cs b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs similarity index 97% rename from API/Data/ManualMigrations/MigrateVolumeLookupName.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs index 9a2a4dbeb..38b7cfbba 100644 --- a/API/Data/ManualMigrations/MigrateVolumeLookupName.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/MigrateVolumeNumber.cs b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeNumber.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateVolumeNumber.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateVolumeNumber.cs diff --git a/API/Data/ManualMigrations/MigrateWantToReadExport.cs b/API/Data/ManualMigrations/v0.7.14/MigrateWantToReadExport.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateWantToReadExport.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateWantToReadExport.cs diff --git a/API/Data/ManualMigrations/MigrateWantToReadImport.cs b/API/Data/ManualMigrations/v0.7.14/MigrateWantToReadImport.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateWantToReadImport.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateWantToReadImport.cs diff --git a/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs b/API/Data/ManualMigrations/v0.7.9/MigrateUserLibrarySideNavStream.cs similarity index 100% rename from API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs rename to API/Data/ManualMigrations/v0.7.9/MigrateUserLibrarySideNavStream.cs diff --git a/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs b/API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs similarity index 99% rename from API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs rename to API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs index 93fc569e8..fac184dc9 100644 --- a/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs +++ b/API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Extensions; using API.Helpers.Builders; using API.Services; diff --git a/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs b/API/Data/ManualMigrations/v0.8.0/ManualMigrateMixedSpecials.cs similarity index 99% rename from API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs rename to API/Data/ManualMigrations/v0.8.0/ManualMigrateMixedSpecials.cs index 4e22abfb8..cda83f05b 100644 --- a/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs +++ b/API/Data/ManualMigrations/v0.8.0/ManualMigrateMixedSpecials.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Extensions; using API.Helpers.Builders; using API.Services; diff --git a/API/Data/ManualMigrations/MigrateChapterFields.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterFields.cs similarity index 99% rename from API/Data/ManualMigrations/MigrateChapterFields.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateChapterFields.cs index f157850fa..7d1f2dd12 100644 --- a/API/Data/ManualMigrations/MigrateChapterFields.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateChapterFields.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/MigrateChapterNumber.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateChapterNumber.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs index 23f256874..e31fa4b92 100644 --- a/API/Data/ManualMigrations/MigrateChapterNumber.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/MigrateChapterRange.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs similarity index 93% rename from API/Data/ManualMigrations/MigrateChapterRange.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs index cd078699f..70a4b30f6 100644 --- a/API/Data/ManualMigrations/MigrateChapterRange.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs @@ -2,6 +2,8 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; +using API.Extensions; using API.Helpers.Builders; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; @@ -28,7 +30,7 @@ public static class MigrateChapterRange var chapters = await dataContext.Chapter.ToListAsync(); foreach (var chapter in chapters) { - if (Parser.MinNumberFromRange(chapter.Range) == 0.0f) + if (Parser.MinNumberFromRange(chapter.Range).Is(0.0f)) { chapter.Range = chapter.GetNumberTitle(); } diff --git a/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs b/API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs similarity index 99% rename from API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs index 038809aab..e29e706d0 100644 --- a/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; +using API.Entities.History; using API.Extensions.QueryExtensions; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/v0.8.0/MigrateDuplicateDarkTheme.cs b/API/Data/ManualMigrations/v0.8.0/MigrateDuplicateDarkTheme.cs new file mode 100644 index 000000000..e414cd8cc --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.0/MigrateDuplicateDarkTheme.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.0 ensured that MangaFile Path is normalized. This will normalize existing data to avoid churn. +/// +public static class MigrateDuplicateDarkTheme +{ + public static async Task Migrate(DataContext dataContext, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateDuplicateDarkTheme")) + { + return; + } + + logger.LogCritical( + "Running MigrateDuplicateDarkTheme migration - Please be patient, this may take some time. This is not an error"); + + var darkThemes = await dataContext.SiteTheme.Where(t => t.Name == "Dark").ToListAsync(); + + if (darkThemes.Count > 1) + { + var correctDarkTheme = darkThemes.First(d => !string.IsNullOrEmpty(d.Description)); + + // Get users + var users = await dataContext.AppUser + .Include(u => u.UserPreferences) + .ThenInclude(p => p.Theme) + .Where(u => u.UserPreferences.Theme.Name == "Dark") + .ToListAsync(); + + // Find any users that have a duplicate Dark theme as default and switch to the correct one + foreach (var user in users) + { + if (string.IsNullOrEmpty(user.UserPreferences.Theme.Description)) + { + user.UserPreferences.Theme = correctDarkTheme; + } + } + await dataContext.SaveChangesAsync(); + + // Now remove the bad themes + dataContext.SiteTheme.RemoveRange(darkThemes.Where(d => string.IsNullOrEmpty(d.Description))); + + await dataContext.SaveChangesAsync(); + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateDuplicateDarkTheme", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + + logger.LogCritical( + "Running MigrateDuplicateDarkTheme migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/MigrateMangaFilePath.cs b/API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateMangaFilePath.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs index ccf9aa773..1dbc7f325 100644 --- a/API/Data/ManualMigrations/MigrateMangaFilePath.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/MigrateProgressExport.cs b/API/Data/ManualMigrations/v0.8.0/MigrateProgressExport.cs similarity index 99% rename from API/Data/ManualMigrations/MigrateProgressExport.cs rename to API/Data/ManualMigrations/v0.8.0/MigrateProgressExport.cs index 2482939c0..631daeea8 100644 --- a/API/Data/ManualMigrations/MigrateProgressExport.cs +++ b/API/Data/ManualMigrations/v0.8.0/MigrateProgressExport.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services; using CsvHelper; using CsvHelper.Configuration.Attributes; diff --git a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs b/API/Data/ManualMigrations/v0.8.1/MigrateLowestSeriesFolderPath.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs rename to API/Data/ManualMigrations/v0.8.1/MigrateLowestSeriesFolderPath.cs index 48978d630..2a68ca3d6 100644 --- a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath.cs +++ b/API/Data/ManualMigrations/v0.8.1/MigrateLowestSeriesFolderPath.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs b/API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs similarity index 98% rename from API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs rename to API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs index 8e648b025..21abfdf10 100644 --- a/API/Data/ManualMigrations/ManualMigrateSwitchToWal.cs +++ b/API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/ManualMigrateThemeDescription.cs b/API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs similarity index 98% rename from API/Data/ManualMigrations/ManualMigrateThemeDescription.cs rename to API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs index 8ac000f0d..e137afe7b 100644 --- a/API/Data/ManualMigrations/ManualMigrateThemeDescription.cs +++ b/API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/MigrateInitialInstallData.cs b/API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs similarity index 92% rename from API/Data/ManualMigrations/MigrateInitialInstallData.cs rename to API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs index f572034d1..851f4ac42 100644 --- a/API/Data/ManualMigrations/MigrateInitialInstallData.cs +++ b/API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs @@ -1,9 +1,11 @@ using System; +using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; using API.Entities.Enums; +using API.Entities.History; using API.Services; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; @@ -34,7 +36,7 @@ public static class MigrateInitialInstallData { var fi = directoryService.FileSystem.FileInfo.New(dbFile); var setting = settings.First(s => s.Key == ServerSettingKey.FirstInstallDate); - setting.Value = fi.CreationTimeUtc.ToString(); + setting.Value = fi.CreationTimeUtc.ToString(CultureInfo.InvariantCulture); await dataContext.SaveChangesAsync(); } diff --git a/API/Data/ManualMigrations/MigrateSeriesLowestFolderPath.cs b/API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateSeriesLowestFolderPath.cs rename to API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs index ca68392cd..8e0db3c10 100644 --- a/API/Data/ManualMigrations/MigrateSeriesLowestFolderPath.cs +++ b/API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; diff --git a/API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs new file mode 100644 index 000000000..fc8b2e586 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.Enums; +using API.Entities.History; +using Flurl.Util; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// At some point, encoding settings wrote bad data to the backend, maybe in v0.8.0. This just fixes any bad data. +/// +public static class ManualMigrateEncodeSettings +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateEncodeSettings")) + { + return; + } + + logger.LogCritical("Running ManualMigrateEncodeSettings migration - Please be patient, this may take some time. This is not an error"); + + + var encodeAs = await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.EncodeMediaAs); + var coverSize = await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CoverImageSize); + + var encodeMap = new Dictionary + { + { EncodeFormat.WEBP.ToString(), ((int)EncodeFormat.WEBP).ToString() }, + { EncodeFormat.PNG.ToString(), ((int)EncodeFormat.PNG).ToString() }, + { EncodeFormat.AVIF.ToString(), ((int)EncodeFormat.AVIF).ToString() } + }; + + if (encodeMap.TryGetValue(encodeAs.Value, out var encodedValue)) + { + encodeAs.Value = encodedValue; + context.ServerSetting.Update(encodeAs); + } + + if (coverSize.Value == "0") + { + coverSize.Value = ((int)CoverImageSize.Default).ToString(); + context.ServerSetting.Update(coverSize); + } + + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateEncodeSettings", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateEncodeSettings migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/ManualMigrateRemovePeople.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs similarity index 95% rename from API/Data/ManualMigrations/ManualMigrateRemovePeople.cs rename to API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs index bf7d5725a..01d9ad45d 100644 --- a/API/Data/ManualMigrations/ManualMigrateRemovePeople.cs +++ b/API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -28,7 +29,7 @@ public static class ManualMigrateRemovePeople logger.LogCritical("Running ManualMigrateRemovePeople migration - Please be patient, this may take some time. This is not an error"); - context.Person.RemoveRange(context.Person); + context.Person.RemoveRange(context.Person); if (context.ChangeTracker.HasChanges()) { diff --git a/API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs new file mode 100644 index 000000000..4f0ed3f96 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.Enums; +using API.Entities.History; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// When I removed Scrobble support for Book libraries, I forgot to turn the setting off for said libraries. +/// +public static class ManualMigrateUnscrobbleBookLibraries +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateUnscrobbleBookLibraries")) + { + return; + } + + logger.LogCritical("Running ManualMigrateUnscrobbleBookLibraries migration - Please be patient, this may take some time. This is not an error"); + + var libs = await context.Library.Where(l => l.Type == LibraryType.Book).ToListAsync(); + foreach (var lib in libs) + { + lib.AllowScrobbling = false; + context.Entry(lib).State = EntityState.Modified; + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateUnscrobbleBookLibraries", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateUnscrobbleBookLibraries migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs b/API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs rename to API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs index bb79c3359..00233852a 100644 --- a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs +++ b/API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs new file mode 100644 index 000000000..60fd170fd --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Entities.Metadata; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - Migrating Kavita+ BlacklistedSeries table to Series entity to streamline implementation and generate a "Needs Manual Match" entry for the Series +/// +public static class ManualMigrateBlacklistTableToSeries +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateBlacklistTableToSeries")) + { + return; + } + + logger.LogCritical("Running ManualMigrateBlacklistTableToSeries migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var blacklistedSeries = await context.SeriesBlacklist + .Include(s => s.Series.ExternalSeriesMetadata) + .Select(s => s.Series) + .ToListAsync(); + + foreach (var series in blacklistedSeries) + { + series.IsBlacklisted = true; + series.ExternalSeriesMetadata ??= new ExternalSeriesMetadata() { SeriesId = series.Id }; + + if (series.ExternalSeriesMetadata.AniListId > 0) + { + series.IsBlacklisted = false; + logger.LogInformation("{SeriesName} was in Blacklist table, but has valid AniList Id, not blacklisting", series.Name); + } + + context.Series.Entry(series).State = EntityState.Modified; + } + // Remove everything in SeriesBlacklist (it will be removed in another migration) + context.SeriesBlacklist.RemoveRange(context.SeriesBlacklist); + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateBlacklistTableToSeries", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateBlacklistTableToSeries migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs new file mode 100644 index 000000000..14bc57cb1 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using API.Entities.Metadata; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - Migrating Kavita+ Series that are Blacklisted but have valid ExternalSeries row +/// +public static class ManualMigrateInvalidBlacklistSeries +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateInvalidBlacklistSeries")) + { + return; + } + + logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var blacklistedSeries = await context.Series + .Include(s => s.ExternalSeriesMetadata) + .Where(s => s.IsBlacklisted && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue) + .ToListAsync(); + + foreach (var series in blacklistedSeries) + { + series.IsBlacklisted = false; + context.Series.Entry(series).State = EntityState.Modified; + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateInvalidBlacklistSeries", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateNeedsManualMatch.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateNeedsManualMatch.cs new file mode 100644 index 000000000..30e4a6d8e --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateNeedsManualMatch.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.KavitaPlus.Manage; +using API.Entities.History; +using API.Entities.Metadata; +using API.Extensions.QueryExtensions; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - After user testing, the needs manual match has some edge cases from migrations and for best user experience, +/// should be reset to allow the upgraded system to process better. +/// +public static class ManualMigrateNeedsManualMatch +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateNeedsManualMatch")) + { + return; + } + + logger.LogCritical("Running ManualMigrateNeedsManualMatch migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var series = await context.Series + .FilterMatchState(MatchStateOption.Error) + .ToListAsync(); + + foreach (var seriesEntry in series) + { + seriesEntry.IsBlacklisted = false; + context.Series.Update(seriesEntry); + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateNeedsManualMatch", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateNeedsManualMatch migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateScrobbleErrors.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateScrobbleErrors.cs new file mode 100644 index 000000000..b0d483de6 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateScrobbleErrors.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - There seems to be some scrobble events that are pre-scrobble error table that can be processed over and over. +/// This will take the given year and minus 1 from it and clear everything from that and anything that is errored. +/// +public static class ManualMigrateScrobbleErrors +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateScrobbleErrors")) + { + return; + } + + logger.LogCritical("Running ManualMigrateScrobbleErrors migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var events = await context.ScrobbleEvent + .Where(se => se.LastModifiedUtc <= DateTime.UtcNow.AddYears(-1) || se.IsErrored) + .ToListAsync(); + + context.ScrobbleEvent.RemoveRange(events); + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + logger.LogInformation("Removed {Count} old scrobble events", events.Count); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateScrobbleErrors", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateScrobbleErrors migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/MigrateProgressExport.cs b/API/Data/ManualMigrations/v0.8.5/MigrateProgressExport.cs new file mode 100644 index 000000000..67488d337 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/MigrateProgressExport.cs @@ -0,0 +1,80 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Services; +using CsvHelper; +using CsvHelper.Configuration.Attributes; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + + +/// +/// v0.8.5 - Progress is extracted and saved in a csv since PDF parser has massive changes +/// +public static class MigrateProgressExportForV085 +{ + public static async Task Migrate(DataContext dataContext, IDirectoryService directoryService, ILogger logger) + { + try + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateProgressExportForV085")) + { + return; + } + + logger.LogCritical( + "Running MigrateProgressExportForV085 migration - Please be patient, this may take some time. This is not an error"); + + var data = await dataContext.AppUserProgresses + .Join(dataContext.Series, progress => progress.SeriesId, series => series.Id, (progress, series) => new { progress, series }) + .Join(dataContext.Volume, ps => ps.progress.VolumeId, volume => volume.Id, (ps, volume) => new { ps.progress, ps.series, volume }) + .Join(dataContext.Chapter, psv => psv.progress.ChapterId, chapter => chapter.Id, (psv, chapter) => new { psv.progress, psv.series, psv.volume, chapter }) + .Join(dataContext.MangaFile, psvc => psvc.chapter.Id, mangaFile => mangaFile.ChapterId, (psvc, mangaFile) => new { psvc.progress, psvc.series, psvc.volume, psvc.chapter, mangaFile }) + .Join(dataContext.AppUser, psvcm => psvcm.progress.AppUserId, appUser => appUser.Id, (psvcm, appUser) => new + { + LibraryId = psvcm.series.LibraryId, + LibraryName = psvcm.series.Library.Name, + SeriesName = psvcm.series.Name, + VolumeRange = psvcm.volume.MinNumber + "-" + psvcm.volume.MaxNumber, + VolumeLookupName = psvcm.volume.Name, + ChapterRange = psvcm.chapter.Range, + MangaFileName = psvcm.mangaFile.FileName, + MangaFilePath = psvcm.mangaFile.FilePath, + AppUserName = appUser.UserName, + AppUserId = appUser.Id, + PagesRead = psvcm.progress.PagesRead, + BookScrollId = psvcm.progress.BookScrollId, + ProgressCreated = psvcm.progress.Created, + ProgressLastModified = psvcm.progress.LastModified + }).ToListAsync(); + + + // Write the mapped data to a CSV file + await using var writer = new StreamWriter(Path.Join(directoryService.ConfigDirectory, "progress_export-v0.8.5.csv")); + await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + await csv.WriteRecordsAsync(data); + + logger.LogCritical( + "Running MigrateProgressExportForV085 migration - Completed. This is not an error"); + } + catch (Exception ex) + { + // On new installs, the db isn't setup yet, so this has nothing to do + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateProgressExportForV085", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + } +} diff --git a/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleEventGen.cs b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleEventGen.cs new file mode 100644 index 000000000..eb51d0fe6 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleEventGen.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.6 - Manually check when a user triggers scrobble event generation +/// +public static class ManualMigrateScrobbleEventGen +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateScrobbleEventGen")) + { + return; + } + + logger.LogCritical("Running ManualMigrateScrobbleEventGen migration - Please be patient, this may take some time. This is not an error"); + + var users = await context.Users + .Where(u => u.AniListAccessToken != null) + .ToListAsync(); + + foreach (var user in users) + { + if (await context.ScrobbleEvent.AnyAsync(se => se.AppUserId == user.Id)) + { + user.HasRunScrobbleEventGeneration = true; + user.ScrobbleEventGenerationRan = DateTime.UtcNow; + context.AppUser.Update(user); + } + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateScrobbleEventGen", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateScrobbleEventGen migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleSpecials.cs b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleSpecials.cs new file mode 100644 index 000000000..4749ff2ec --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleSpecials.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.6 - Change to not scrobble specials as they will never process, this migration removes all existing scrobble events +/// +public static class ManualMigrateScrobbleSpecials +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateScrobbleSpecials")) + { + return; + } + + logger.LogCritical("Running ManualMigrateScrobbleSpecials migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var events = await context.ScrobbleEvent + .Where(se => se.VolumeNumber == Parser.SpecialVolumeNumber) + .ToListAsync(); + + context.ScrobbleEvent.RemoveRange(events); + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + logger.LogInformation("Removed {Count} scrobble events that were specials", events.Count); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateScrobbleSpecials", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateScrobbleSpecials migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs b/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs new file mode 100644 index 000000000..b2afde98a --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.Enums; +using API.Entities.History; +using API.Extensions; +using API.Helpers.Builders; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +public static class ManualMigrateReadingProfiles +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateReadingProfiles")) + { + return; + } + + logger.LogCritical("Running ManualMigrateReadingProfiles migration - Please be patient, this may take some time. This is not an error"); + + var users = await context.AppUser + .Include(u => u.UserPreferences) + .Include(u => u.ReadingProfiles) + .ToListAsync(); + + foreach (var user in users) + { + var readingProfile = new AppUserReadingProfile + { + Name = "Default", + NormalizedName = "Default".ToNormalized(), + Kind = ReadingProfileKind.Default, + LibraryIds = [], + SeriesIds = [], + BackgroundColor = user.UserPreferences.BackgroundColor, + EmulateBook = user.UserPreferences.EmulateBook, + AppUser = user, + PdfTheme = user.UserPreferences.PdfTheme, + ReaderMode = user.UserPreferences.ReaderMode, + ReadingDirection = user.UserPreferences.ReadingDirection, + ScalingOption = user.UserPreferences.ScalingOption, + LayoutMode = user.UserPreferences.LayoutMode, + WidthOverride = null, + AppUserId = user.Id, + AutoCloseMenu = user.UserPreferences.AutoCloseMenu, + BookReaderMargin = user.UserPreferences.BookReaderMargin, + PageSplitOption = user.UserPreferences.PageSplitOption, + BookThemeName = user.UserPreferences.BookThemeName, + PdfSpreadMode = user.UserPreferences.PdfSpreadMode, + PdfScrollMode = user.UserPreferences.PdfScrollMode, + SwipeToPaginate = user.UserPreferences.SwipeToPaginate, + BookReaderFontFamily = user.UserPreferences.BookReaderFontFamily, + BookReaderFontSize = user.UserPreferences.BookReaderFontSize, + BookReaderImmersiveMode = user.UserPreferences.BookReaderImmersiveMode, + BookReaderLayoutMode = user.UserPreferences.BookReaderLayoutMode, + BookReaderLineSpacing = user.UserPreferences.BookReaderLineSpacing, + BookReaderReadingDirection = user.UserPreferences.BookReaderReadingDirection, + BookReaderWritingStyle = user.UserPreferences.BookReaderWritingStyle, + AllowAutomaticWebtoonReaderDetection = user.UserPreferences.AllowAutomaticWebtoonReaderDetection, + BookReaderTapToPaginate = user.UserPreferences.BookReaderTapToPaginate, + ShowScreenHints = user.UserPreferences.ShowScreenHints, + }; + user.ReadingProfiles.Add(readingProfile); + } + + await context.SaveChangesAsync(); + + context.ManualMigrationHistory.Add(new ManualMigrationHistory + { + Name = "ManualMigrateReadingProfiles", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow, + }); + await context.SaveChangesAsync(); + + + logger.LogCritical("Running ManualMigrateReadingProfiles migration - Completed. This is not an error"); + + } +} diff --git a/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.Designer.cs b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.Designer.cs new file mode 100644 index 000000000..a5158ebc1 --- /dev/null +++ b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.Designer.cs @@ -0,0 +1,3203 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250105180131_SeriesDontMatchAndBlacklist")] + partial class SeriesDontMatchAndBlacklist + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.cs b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.cs new file mode 100644 index 000000000..ab80f0621 --- /dev/null +++ b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SeriesDontMatchAndBlacklist : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DontMatch", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsBlacklisted", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DontMatch", + table: "Series"); + + migrationBuilder.DropColumn( + name: "IsBlacklisted", + table: "Series"); + } + } +} diff --git a/API/Data/Migrations/20250109173537_EmailHistory.Designer.cs b/API/Data/Migrations/20250109173537_EmailHistory.Designer.cs new file mode 100644 index 000000000..ff3212562 --- /dev/null +++ b/API/Data/Migrations/20250109173537_EmailHistory.Designer.cs @@ -0,0 +1,3265 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250109173537_EmailHistory")] + partial class EmailHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250109173537_EmailHistory.cs b/API/Data/Migrations/20250109173537_EmailHistory.cs new file mode 100644 index 000000000..b31bf20c3 --- /dev/null +++ b/API/Data/Migrations/20250109173537_EmailHistory.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class EmailHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EmailHistory", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Sent = table.Column(type: "INTEGER", nullable: false), + SendDate = table.Column(type: "TEXT", nullable: false), + EmailTemplate = table.Column(type: "TEXT", nullable: true), + Subject = table.Column(type: "TEXT", nullable: true), + Body = table.Column(type: "TEXT", nullable: true), + DeliveryStatus = table.Column(type: "TEXT", nullable: true), + ErrorMessage = table.Column(type: "TEXT", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + LastModifiedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailHistory", x => x.Id); + table.ForeignKey( + name: "FK_EmailHistory_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_EmailHistory_AppUserId", + table: "EmailHistory", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_EmailHistory_Sent_AppUserId_EmailTemplate_SendDate", + table: "EmailHistory", + columns: new[] { "Sent", "AppUserId", "EmailTemplate", "SendDate" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EmailHistory"); + } + } +} diff --git a/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs new file mode 100644 index 000000000..835510a1e --- /dev/null +++ b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs @@ -0,0 +1,3382 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250202163454_KavitaPlusUserAndMetadataSettings")] + partial class KavitaPlusUserAndMetadataSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs new file mode 100644 index 000000000..b23d7896b --- /dev/null +++ b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs @@ -0,0 +1,112 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class KavitaPlusUserAndMetadataSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowMetadataMatching", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "AniListScrobblingEnabled", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "WantToReadSync", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.CreateTable( + name: "MetadataSettings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + EnableSummary = table.Column(type: "INTEGER", nullable: false), + EnablePublicationStatus = table.Column(type: "INTEGER", nullable: false), + EnableRelationships = table.Column(type: "INTEGER", nullable: false), + EnablePeople = table.Column(type: "INTEGER", nullable: false), + EnableStartDate = table.Column(type: "INTEGER", nullable: false), + EnableLocalizedName = table.Column(type: "INTEGER", nullable: false), + EnableGenres = table.Column(type: "INTEGER", nullable: false), + EnableTags = table.Column(type: "INTEGER", nullable: false), + FirstLastPeopleNaming = table.Column(type: "INTEGER", nullable: false), + AgeRatingMappings = table.Column(type: "TEXT", nullable: true), + Blacklist = table.Column(type: "TEXT", nullable: true), + Whitelist = table.Column(type: "TEXT", nullable: true), + PersonRoles = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MetadataSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MetadataFieldMapping", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SourceType = table.Column(type: "INTEGER", nullable: false), + DestinationType = table.Column(type: "INTEGER", nullable: false), + SourceValue = table.Column(type: "TEXT", nullable: true), + DestinationValue = table.Column(type: "TEXT", nullable: true), + ExcludeFromSource = table.Column(type: "INTEGER", nullable: false), + MetadataSettingsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MetadataFieldMapping", x => x.Id); + table.ForeignKey( + name: "FK_MetadataFieldMapping_MetadataSettings_MetadataSettingsId", + column: x => x.MetadataSettingsId, + principalTable: "MetadataSettings", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MetadataFieldMapping_MetadataSettingsId", + table: "MetadataFieldMapping", + column: "MetadataSettingsId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MetadataFieldMapping"); + + migrationBuilder.DropTable( + name: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "AllowMetadataMatching", + table: "Library"); + + migrationBuilder.DropColumn( + name: "AniListScrobblingEnabled", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "WantToReadSync", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs new file mode 100644 index 000000000..9aaa63101 --- /dev/null +++ b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs @@ -0,0 +1,3398 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250208200843_MoreMetadtaSettings")] + partial class MoreMetadtaSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs new file mode 100644 index 000000000..70e42cd11 --- /dev/null +++ b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class MoreMetadtaSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "KavitaPlusConnection", + table: "SeriesMetadataPeople", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "OrderWeight", + table: "SeriesMetadataPeople", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "EnableCoverImage", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "Overrides", + table: "MetadataSettings", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "KavitaPlusConnection", + table: "SeriesMetadataPeople"); + + migrationBuilder.DropColumn( + name: "OrderWeight", + table: "SeriesMetadataPeople"); + + migrationBuilder.DropColumn( + name: "EnableCoverImage", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "Overrides", + table: "MetadataSettings"); + } + } +} diff --git a/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs new file mode 100644 index 000000000..be3d5e3f9 --- /dev/null +++ b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs @@ -0,0 +1,3403 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250328125012_AutomaticWebtoonReaderMode")] + partial class AutomaticWebtoonReaderMode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs new file mode 100644 index 000000000..38b772811 --- /dev/null +++ b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AutomaticWebtoonReaderMode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowAutomaticWebtoonReaderDetection", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AllowAutomaticWebtoonReaderDetection", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.Designer.cs b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.Designer.cs new file mode 100644 index 000000000..53e450b3b --- /dev/null +++ b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.Designer.cs @@ -0,0 +1,3409 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250408222330_ScrobbleGenerationDbCapture")] + partial class ScrobbleGenerationDbCapture + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.cs b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.cs new file mode 100644 index 000000000..7431a7338 --- /dev/null +++ b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ScrobbleGenerationDbCapture : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HasRunScrobbleEventGeneration", + table: "AspNetUsers", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ScrobbleEventGenerationRan", + table: "AspNetUsers", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HasRunScrobbleEventGeneration", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "ScrobbleEventGenerationRan", + table: "AspNetUsers"); + } + } +} diff --git a/API/Data/Migrations/20250415194829_KavitaPlusCBR.Designer.cs b/API/Data/Migrations/20250415194829_KavitaPlusCBR.Designer.cs new file mode 100644 index 000000000..fd287c085 --- /dev/null +++ b/API/Data/Migrations/20250415194829_KavitaPlusCBR.Designer.cs @@ -0,0 +1,3433 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250415194829_KavitaPlusCBR")] + partial class KavitaPlusCBR + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250415194829_KavitaPlusCBR.cs b/API/Data/Migrations/20250415194829_KavitaPlusCBR.cs new file mode 100644 index 000000000..188969476 --- /dev/null +++ b/API/Data/Migrations/20250415194829_KavitaPlusCBR.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class KavitaPlusCBR : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnableChapterCoverImage", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterPublisher", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterReleaseDate", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterSummary", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterTitle", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CbrId", + table: "ExternalSeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "KavitaPlusConnection", + table: "ChapterPeople", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "OrderWeight", + table: "ChapterPeople", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnableChapterCoverImage", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterPublisher", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterReleaseDate", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterSummary", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterTitle", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "CbrId", + table: "ExternalSeriesMetadata"); + + migrationBuilder.DropColumn( + name: "KavitaPlusConnection", + table: "ChapterPeople"); + + migrationBuilder.DropColumn( + name: "OrderWeight", + table: "ChapterPeople"); + } + } +} diff --git a/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.Designer.cs b/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.Designer.cs new file mode 100644 index 000000000..52e2c4a86 --- /dev/null +++ b/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.Designer.cs @@ -0,0 +1,3536 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250429150140_ChapterRatingAndReviews")] + partial class ChapterRatingAndReviews + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.cs b/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.cs new file mode 100644 index 000000000..5ab51aaba --- /dev/null +++ b/API/Data/Migrations/20250429150140_ChapterRatingAndReviews.cs @@ -0,0 +1,165 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ChapterRatingAndReviews : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Authority", + table: "ExternalReview", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ChapterId", + table: "ExternalReview", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "Authority", + table: "ExternalRating", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ChapterId", + table: "ExternalRating", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "AverageExternalRating", + table: "Chapter", + type: "REAL", + nullable: false, + defaultValue: 0f); + + migrationBuilder.CreateTable( + name: "AppUserChapterRating", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Rating = table.Column(type: "REAL", nullable: false), + HasBeenRated = table.Column(type: "INTEGER", nullable: false), + Review = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "INTEGER", nullable: false), + ChapterId = table.Column(type: "INTEGER", nullable: false), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserChapterRating", x => x.Id); + table.ForeignKey( + name: "FK_AppUserChapterRating_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppUserChapterRating_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppUserChapterRating_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExternalReview_ChapterId", + table: "ExternalReview", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_ExternalRating_ChapterId", + table: "ExternalRating", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserChapterRating_AppUserId", + table: "AppUserChapterRating", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserChapterRating_ChapterId", + table: "AppUserChapterRating", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserChapterRating_SeriesId", + table: "AppUserChapterRating", + column: "SeriesId"); + + migrationBuilder.AddForeignKey( + name: "FK_ExternalRating_Chapter_ChapterId", + table: "ExternalRating", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_ExternalReview_Chapter_ChapterId", + table: "ExternalReview", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ExternalRating_Chapter_ChapterId", + table: "ExternalRating"); + + migrationBuilder.DropForeignKey( + name: "FK_ExternalReview_Chapter_ChapterId", + table: "ExternalReview"); + + migrationBuilder.DropTable( + name: "AppUserChapterRating"); + + migrationBuilder.DropIndex( + name: "IX_ExternalReview_ChapterId", + table: "ExternalReview"); + + migrationBuilder.DropIndex( + name: "IX_ExternalRating_ChapterId", + table: "ExternalRating"); + + migrationBuilder.DropColumn( + name: "Authority", + table: "ExternalReview"); + + migrationBuilder.DropColumn( + name: "ChapterId", + table: "ExternalReview"); + + migrationBuilder.DropColumn( + name: "Authority", + table: "ExternalRating"); + + migrationBuilder.DropColumn( + name: "ChapterId", + table: "ExternalRating"); + + migrationBuilder.DropColumn( + name: "AverageExternalRating", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs b/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs new file mode 100644 index 000000000..5d76571e1 --- /dev/null +++ b/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs @@ -0,0 +1,3571 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250507221026_PersonAliases")] + partial class PersonAliases + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250507221026_PersonAliases.cs b/API/Data/Migrations/20250507221026_PersonAliases.cs new file mode 100644 index 000000000..cb046a131 --- /dev/null +++ b/API/Data/Migrations/20250507221026_PersonAliases.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PersonAliases : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PersonAlias", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Alias = table.Column(type: "TEXT", nullable: true), + NormalizedAlias = table.Column(type: "TEXT", nullable: true), + PersonId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PersonAlias", x => x.Id); + table.ForeignKey( + name: "FK_PersonAlias_Person_PersonId", + column: x => x.PersonId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PersonAlias_PersonId", + table: "PersonAlias", + column: "PersonId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PersonAlias"); + } + } +} diff --git a/API/Data/Migrations/20250519151126_KoreaderHash.Designer.cs b/API/Data/Migrations/20250519151126_KoreaderHash.Designer.cs new file mode 100644 index 000000000..79f6f9504 --- /dev/null +++ b/API/Data/Migrations/20250519151126_KoreaderHash.Designer.cs @@ -0,0 +1,3574 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250519151126_KoreaderHash")] + partial class KoreaderHash + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250519151126_KoreaderHash.cs b/API/Data/Migrations/20250519151126_KoreaderHash.cs new file mode 100644 index 000000000..006070b72 --- /dev/null +++ b/API/Data/Migrations/20250519151126_KoreaderHash.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class KoreaderHash : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "KoreaderHash", + table: "MangaFile", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "KoreaderHash", + table: "MangaFile"); + } + } +} diff --git a/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs b/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs new file mode 100644 index 000000000..762eae142 --- /dev/null +++ b/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs @@ -0,0 +1,3698 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250601200056_ReadingProfiles")] + partial class ReadingProfiles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250601200056_ReadingProfiles.cs b/API/Data/Migrations/20250601200056_ReadingProfiles.cs new file mode 100644 index 000000000..66b9e53e5 --- /dev/null +++ b/API/Data/Migrations/20250601200056_ReadingProfiles.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ReadingProfiles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppUserReadingProfiles", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false), + Kind = table.Column(type: "INTEGER", nullable: false), + LibraryIds = table.Column(type: "TEXT", nullable: true), + SeriesIds = table.Column(type: "TEXT", nullable: true), + ReadingDirection = table.Column(type: "INTEGER", nullable: false), + ScalingOption = table.Column(type: "INTEGER", nullable: false), + PageSplitOption = table.Column(type: "INTEGER", nullable: false), + ReaderMode = table.Column(type: "INTEGER", nullable: false), + AutoCloseMenu = table.Column(type: "INTEGER", nullable: false), + ShowScreenHints = table.Column(type: "INTEGER", nullable: false), + EmulateBook = table.Column(type: "INTEGER", nullable: false), + LayoutMode = table.Column(type: "INTEGER", nullable: false), + BackgroundColor = table.Column(type: "TEXT", nullable: true, defaultValue: "#000000"), + SwipeToPaginate = table.Column(type: "INTEGER", nullable: false), + AllowAutomaticWebtoonReaderDetection = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + WidthOverride = table.Column(type: "INTEGER", nullable: true), + BookReaderMargin = table.Column(type: "INTEGER", nullable: false), + BookReaderLineSpacing = table.Column(type: "INTEGER", nullable: false), + BookReaderFontSize = table.Column(type: "INTEGER", nullable: false), + BookReaderFontFamily = table.Column(type: "TEXT", nullable: true), + BookReaderTapToPaginate = table.Column(type: "INTEGER", nullable: false), + BookReaderReadingDirection = table.Column(type: "INTEGER", nullable: false), + BookReaderWritingStyle = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + BookThemeName = table.Column(type: "TEXT", nullable: true, defaultValue: "Dark"), + BookReaderLayoutMode = table.Column(type: "INTEGER", nullable: false), + BookReaderImmersiveMode = table.Column(type: "INTEGER", nullable: false), + PdfTheme = table.Column(type: "INTEGER", nullable: false), + PdfScrollMode = table.Column(type: "INTEGER", nullable: false), + PdfSpreadMode = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserReadingProfiles", x => x.Id); + table.ForeignKey( + name: "FK_AppUserReadingProfiles_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserReadingProfiles_AppUserId", + table: "AppUserReadingProfiles", + column: "AppUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserReadingProfiles"); + } + } +} diff --git a/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs new file mode 100644 index 000000000..0e9f00b4e --- /dev/null +++ b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs @@ -0,0 +1,3701 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint")] + partial class AppUserReadingProfileDisableWidthOverrideBreakPoint + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs new file mode 100644 index 000000000..11a554bdf --- /dev/null +++ b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AppUserReadingProfileDisableWidthOverrideBreakPoint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DisableWidthOverride", + table: "AppUserReadingProfiles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DisableWidthOverride", + table: "AppUserReadingProfiles"); + } + } +} diff --git a/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs new file mode 100644 index 000000000..c15f9f77b --- /dev/null +++ b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs @@ -0,0 +1,3709 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250620215058_EnableMetadataLibrary")] + partial class EnableMetadataLibrary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs new file mode 100644 index 000000000..f9e38c01d --- /dev/null +++ b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class EnableMetadataLibrary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnableMetadata", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnableMetadata", + table: "Library"); + } + } +} diff --git a/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.Designer.cs b/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.Designer.cs new file mode 100644 index 000000000..b72239924 --- /dev/null +++ b/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.Designer.cs @@ -0,0 +1,3721 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250626162548_TrackKavitaPlusMetadata")] + partial class TrackKavitaPlusMetadata + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.cs b/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.cs new file mode 100644 index 000000000..ac253e0a8 --- /dev/null +++ b/API/Data/Migrations/20250626162548_TrackKavitaPlusMetadata.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class TrackKavitaPlusMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "KPlusOverrides", + table: "SeriesMetadata", + type: "TEXT", + nullable: true, + defaultValue: "[]"); + + migrationBuilder.AddColumn( + name: "KPlusOverrides", + table: "Chapter", + type: "TEXT", + nullable: true, + defaultValue: "[]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "KPlusOverrides", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "KPlusOverrides", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.Designer.cs b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.Designer.cs new file mode 100644 index 000000000..165663f3d --- /dev/null +++ b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.Designer.cs @@ -0,0 +1,3724 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250629153840_LibraryRemoveSortPrefix")] + partial class LibraryRemoveSortPrefix + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.cs b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.cs new file mode 100644 index 000000000..4800cf3fa --- /dev/null +++ b/API/Data/Migrations/20250629153840_LibraryRemoveSortPrefix.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class LibraryRemoveSortPrefix : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RemovePrefixForSortName", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RemovePrefixForSortName", + table: "Library"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index ddcfeb10e..62d1fb1ef 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -1,6 +1,8 @@ // using System; +using System.Collections.Generic; using API.Data; +using API.Entities.MetadataMatching; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -15,7 +17,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -85,6 +87,9 @@ namespace API.Data.Migrations b.Property("EmailConfirmed") .HasColumnType("INTEGER"); + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + b.Property("LastActive") .HasColumnType("TEXT"); @@ -124,6 +129,9 @@ namespace API.Data.Migrations .IsConcurrencyToken() .HasColumnType("INTEGER"); + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + b.Property("SecurityStamp") .HasColumnType("TEXT"); @@ -189,6 +197,41 @@ namespace API.Data.Migrations b.ToTable("AppUserBookmark"); }); + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + modelBuilder.Entity("API.Entities.AppUserCollection", b => { b.Property("Id") @@ -353,6 +396,16 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("AppUserId") .HasColumnType("INTEGER"); @@ -460,6 +513,11 @@ namespace API.Data.Migrations b.Property("ThemeId") .HasColumnType("INTEGER"); + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.HasKey("Id"); b.HasIndex("AppUserId") @@ -553,6 +611,123 @@ namespace API.Data.Migrations b.ToTable("AppUserRating"); }); + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + modelBuilder.Entity("API.Entities.AppUserRole", b => { b.Property("UserId") @@ -731,6 +906,9 @@ namespace API.Data.Migrations b.Property("AlternateSeries") .HasColumnType("TEXT"); + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + b.Property("AvgHoursToRead") .HasColumnType("REAL"); @@ -781,6 +959,11 @@ namespace API.Data.Migrations b.Property("IsSpecial") .HasColumnType("INTEGER"); + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + b.Property("Language") .HasColumnType("TEXT"); @@ -901,24 +1084,6 @@ namespace API.Data.Migrations b.ToTable("Chapter"); }); - modelBuilder.Entity("API.Entities.ChapterPeople", b => - { - b.Property("ChapterId") - .HasColumnType("INTEGER"); - - b.Property("PersonId") - .HasColumnType("INTEGER"); - - b.Property("Role") - .HasColumnType("INTEGER"); - - b.HasKey("ChapterId", "PersonId", "Role"); - - b.HasIndex("PersonId"); - - b.ToTable("ChapterPeople"); - }); - modelBuilder.Entity("API.Entities.CollectionTag", b => { b.Property("Id") @@ -1000,6 +1165,57 @@ namespace API.Data.Migrations b.ToTable("Device"); }); + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.Property("Id") @@ -1042,12 +1258,37 @@ namespace API.Data.Migrations b.ToTable("Genre"); }); + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + modelBuilder.Entity("API.Entities.Library", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("AllowScrobbling") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -1062,6 +1303,11 @@ namespace API.Data.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("FolderWatching") .HasColumnType("INTEGER"); @@ -1095,6 +1341,9 @@ namespace API.Data.Migrations b.Property("PrimaryColor") .HasColumnType("TEXT"); + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + b.Property("SecondaryColor") .HasColumnType("TEXT"); @@ -1174,6 +1423,9 @@ namespace API.Data.Migrations b.Property("Format") .HasColumnType("INTEGER"); + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + b.Property("LastFileAnalysis") .HasColumnType("TEXT"); @@ -1196,26 +1448,6 @@ namespace API.Data.Migrations b.ToTable("MangaFile"); }); - modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Name") - .HasColumnType("TEXT"); - - b.Property("ProductVersion") - .HasColumnType("TEXT"); - - b.Property("RanAt") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("ManualMigrationHistory"); - }); - modelBuilder.Entity("API.Entities.MediaError", b => { b.Property("Id") @@ -1257,9 +1489,15 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("Authority") + .HasColumnType("INTEGER"); + b.Property("AverageScore") .HasColumnType("INTEGER"); + b.Property("ChapterId") + .HasColumnType("INTEGER"); + b.Property("FavoriteCount") .HasColumnType("INTEGER"); @@ -1274,6 +1512,8 @@ namespace API.Data.Migrations b.HasKey("Id"); + b.HasIndex("ChapterId"); + b.ToTable("ExternalRating"); }); @@ -1320,12 +1560,18 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("Authority") + .HasColumnType("INTEGER"); + b.Property("Body") .HasColumnType("TEXT"); b.Property("BodyJustText") .HasColumnType("TEXT"); + b.Property("ChapterId") + .HasColumnType("INTEGER"); + b.Property("Provider") .HasColumnType("INTEGER"); @@ -1355,6 +1601,8 @@ namespace API.Data.Migrations b.HasKey("Id"); + b.HasIndex("ChapterId"); + b.ToTable("ExternalReview"); }); @@ -1370,6 +1618,9 @@ namespace API.Data.Migrations b.Property("AverageExternalRating") .HasColumnType("INTEGER"); + b.Property("CbrId") + .HasColumnType("INTEGER"); + b.Property("GoogleBooksId") .HasColumnType("TEXT"); @@ -1442,6 +1693,11 @@ namespace API.Data.Migrations b.Property("InkerLocked") .HasColumnType("INTEGER"); + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + b.Property("Language") .HasColumnType("TEXT"); @@ -1543,7 +1799,140 @@ namespace API.Data.Migrations b.ToTable("SeriesRelation"); }); - modelBuilder.Entity("API.Entities.Person", b => + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1587,6 +1976,54 @@ namespace API.Data.Migrations b.ToTable("Person"); }); + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.Property("Id") @@ -1866,12 +2303,18 @@ namespace API.Data.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("DontMatch") + .HasColumnType("INTEGER"); + b.Property("FolderPath") .HasColumnType("TEXT"); b.Property("Format") .HasColumnType("INTEGER"); + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + b.Property("LastChapterAdded") .HasColumnType("TEXT"); @@ -1945,24 +2388,6 @@ namespace API.Data.Migrations b.ToTable("Series"); }); - modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => - { - b.Property("SeriesMetadataId") - .HasColumnType("INTEGER"); - - b.Property("PersonId") - .HasColumnType("INTEGER"); - - b.Property("Role") - .HasColumnType("INTEGER"); - - b.HasKey("SeriesMetadataId", "PersonId", "Role"); - - b.HasIndex("PersonId"); - - b.ToTable("SeriesMetadataPeople"); - }); - modelBuilder.Entity("API.Entities.ServerSetting", b => { b.Property("Key") @@ -2409,6 +2834,33 @@ namespace API.Data.Migrations b.Navigation("AppUser"); }); + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + modelBuilder.Entity("API.Entities.AppUserCollection", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2526,6 +2978,17 @@ namespace API.Data.Migrations b.Navigation("Series"); }); + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.AppUserRole", b => { b.HasOne("API.Entities.AppRole", "Role") @@ -2630,25 +3093,6 @@ namespace API.Data.Migrations b.Navigation("Volume"); }); - modelBuilder.Entity("API.Entities.ChapterPeople", b => - { - b.HasOne("API.Entities.Chapter", "Chapter") - .WithMany("People") - .HasForeignKey("ChapterId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("API.Entities.Person", "Person") - .WithMany("ChapterPeople") - .HasForeignKey("PersonId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Chapter"); - - b.Navigation("Person"); - }); - modelBuilder.Entity("API.Entities.Device", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2660,6 +3104,17 @@ namespace API.Data.Migrations b.Navigation("AppUser"); }); + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.HasOne("API.Entities.Library", "Library") @@ -2704,6 +3159,20 @@ namespace API.Data.Migrations b.Navigation("Chapter"); }); + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => { b.HasOne("API.Entities.Series", "Series") @@ -2756,6 +3225,66 @@ namespace API.Data.Migrations b.Navigation("TargetSeries"); }); + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2876,25 +3405,6 @@ namespace API.Data.Migrations b.Navigation("Library"); }); - modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => - { - b.HasOne("API.Entities.Person", "Person") - .WithMany("SeriesMetadataPeople") - .HasForeignKey("PersonId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") - .WithMany("People") - .HasForeignKey("SeriesMetadataId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Person"); - - b.Navigation("SeriesMetadata"); - }); - modelBuilder.Entity("API.Entities.Volume", b => { b.HasOne("API.Entities.Series", "Series") @@ -3101,6 +3611,8 @@ namespace API.Data.Migrations { b.Navigation("Bookmarks"); + b.Navigation("ChapterRatings"); + b.Navigation("Collections"); b.Navigation("DashboardStreams"); @@ -3115,6 +3627,8 @@ namespace API.Data.Migrations b.Navigation("ReadingLists"); + b.Navigation("ReadingProfiles"); + b.Navigation("ScrobbleHolds"); b.Navigation("SideNavStreams"); @@ -3132,10 +3646,16 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.Chapter", b => { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + b.Navigation("Files"); b.Navigation("People"); + b.Navigation("Ratings"); + b.Navigation("UserProgress"); }); @@ -3155,8 +3675,15 @@ namespace API.Data.Migrations b.Navigation("People"); }); - modelBuilder.Entity("API.Entities.Person", b => + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + b.Navigation("ChapterPeople"); b.Navigation("SeriesMetadataPeople"); diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 3b065f2e0..a672259ad 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -16,9 +16,11 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; #nullable enable + public interface IAppUserProgressRepository { void Update(AppUserProgress userProgress); + void Remove(AppUserProgress userProgress); Task CleanupAbandonedChapters(); Task UserHasProgress(LibraryType libraryType, int userId); Task GetUserProgressAsync(int chapterId, int userId); @@ -40,7 +42,7 @@ public interface IAppUserProgressRepository Task UpdateAllProgressThatAreMoreThanChapterPages(); Task> GetUserProgressForChapter(int chapterId, int userId = 0); } -#nullable disable + public class AppUserProgressRepository : IAppUserProgressRepository { private readonly DataContext _context; @@ -57,6 +59,11 @@ public class AppUserProgressRepository : IAppUserProgressRepository _context.Entry(userProgress).State = EntityState.Modified; } + public void Remove(AppUserProgress userProgress) + { + _context.Remove(userProgress); + } + /// /// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well. /// @@ -186,6 +193,7 @@ public class AppUserProgressRepository : IAppUserProgressRepository .Where(p => p.chapter.MaxNumber != Parser.SpecialVolumeNumber) .Select(p => p.chapter.Volume.MaxNumber) .ToListAsync(); + return list.Count == 0 ? 0 : list.DefaultIfEmpty().Max(); } diff --git a/API/Data/Repositories/AppUserReadingProfileRepository.cs b/API/Data/Repositories/AppUserReadingProfileRepository.cs new file mode 100644 index 000000000..11b97f21a --- /dev/null +++ b/API/Data/Repositories/AppUserReadingProfileRepository.cs @@ -0,0 +1,112 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Extensions.QueryExtensions; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + + +public interface IAppUserReadingProfileRepository +{ + + /// + /// Return the given profile if it belongs the user + /// + /// + /// + /// + Task GetUserProfile(int userId, int profileId); + /// + /// Returns all reading profiles for the user + /// + /// + /// + Task> GetProfilesForUser(int userId, bool skipImplicit = false); + /// + /// Returns all reading profiles for the user + /// + /// + /// + Task> GetProfilesDtoForUser(int userId, bool skipImplicit = false); + /// + /// Is there a user reading profile with this name (normalized) + /// + /// + /// + /// + Task IsProfileNameInUse(int userId, string name); + + void Add(AppUserReadingProfile readingProfile); + void Update(AppUserReadingProfile readingProfile); + void Remove(AppUserReadingProfile readingProfile); + void RemoveRange(IEnumerable readingProfiles); +} + +public class AppUserReadingProfileRepository(DataContext context, IMapper mapper): IAppUserReadingProfileRepository +{ + public async Task GetUserProfile(int userId, int profileId) + { + return await context.AppUserReadingProfiles + .Where(rp => rp.AppUserId == userId && rp.Id == profileId) + .FirstOrDefaultAsync(); + } + + public async Task> GetProfilesForUser(int userId, bool skipImplicit = false) + { + return await context.AppUserReadingProfiles + .Where(rp => rp.AppUserId == userId) + .WhereIf(skipImplicit, rp => rp.Kind != ReadingProfileKind.Implicit) + .ToListAsync(); + } + + /// + /// Returns all Reading Profiles for the User + /// + /// + /// + public async Task> GetProfilesDtoForUser(int userId, bool skipImplicit = false) + { + return await context.AppUserReadingProfiles + .Where(rp => rp.AppUserId == userId) + .WhereIf(skipImplicit, rp => rp.Kind != ReadingProfileKind.Implicit) + .ProjectTo(mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task IsProfileNameInUse(int userId, string name) + { + var normalizedName = name.ToNormalized(); + + return await context.AppUserReadingProfiles + .Where(rp => rp.NormalizedName == normalizedName && rp.AppUserId == userId) + .AnyAsync(); + } + + public void Add(AppUserReadingProfile readingProfile) + { + context.AppUserReadingProfiles.Add(readingProfile); + } + + public void Update(AppUserReadingProfile readingProfile) + { + context.AppUserReadingProfiles.Update(readingProfile).State = EntityState.Modified; + } + + public void Remove(AppUserReadingProfile readingProfile) + { + context.AppUserReadingProfiles.Remove(readingProfile); + } + + public void RemoveRange(IEnumerable readingProfiles) + { + context.AppUserReadingProfiles.RemoveRange(readingProfiles); + } +} diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 21eee7d31..27d21df74 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -5,8 +5,10 @@ using System.Threading.Tasks; using API.DTOs; using API.DTOs.Metadata; using API.DTOs.Reader; +using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Extensions; using API.Extensions.QueryExtensions; using AutoMapper; @@ -24,13 +26,16 @@ public enum ChapterIncludes Files = 4, People = 8, Genres = 16, - Tags = 32 + Tags = 32, + ExternalReviews = 1 << 6, + ExternalRatings = 1 << 7 } public interface IChapterRepository { void Update(Chapter chapter); void Remove(Chapter chapter); + void Remove(IList chapters); Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None); Task GetChapterInfoDtoAsync(int chapterId); Task GetChapterTotalPagesAsync(int chapterId); @@ -46,6 +51,12 @@ public interface IChapterRepository Task> GetCoverImagesForLockedChaptersAsync(); Task AddChapterModifiers(int userId, ChapterDto chapter); IEnumerable GetChaptersForSeries(int seriesId); + Task> GetAllChaptersForSeries(int seriesId); + Task GetAverageUserRating(int chapterId, int userId); + Task> GetExternalChapterReviewDtos(int chapterId); + Task> GetExternalChapterReview(int chapterId); + Task> GetExternalChapterRatingDtos(int chapterId); + Task> GetExternalChapterRatings(int chapterId); } public class ChapterRepository : IChapterRepository { @@ -68,6 +79,11 @@ public class ChapterRepository : IChapterRepository _context.Chapter.Remove(chapter); } + public void Remove(IList chapters) + { + _context.Chapter.RemoveRange(chapters); + } + public async Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None) { return await _context.Chapter @@ -292,4 +308,66 @@ public class ChapterRepository : IChapterRepository .Include(c => c.Volume) .AsEnumerable(); } + + public async Task> GetAllChaptersForSeries(int seriesId) + { + return await _context.Chapter + .Where(c => c.Volume.SeriesId == seriesId) + .OrderBy(c => c.SortOrder) + .Include(c => c.Volume) + .Include(c => c.People) + .ThenInclude(cp => cp.Person) + .ToListAsync(); + } + + public async Task GetAverageUserRating(int chapterId, int userId) + { + // If there is 0 or 1 rating and that rating is you, return 0 back + var countOfRatingsThatAreUser = await _context.AppUserChapterRating + .Where(r => r.ChapterId == chapterId && r.HasBeenRated) + .CountAsync(u => u.AppUserId == userId); + if (countOfRatingsThatAreUser == 1) + { + return 0; + } + var avg = (await _context.AppUserChapterRating + .Where(r => r.ChapterId == chapterId && r.HasBeenRated) + .AverageAsync(r => (int?) r.Rating)); + return avg.HasValue ? (int) (avg.Value * 20) : 0; + } + + public async Task> GetExternalChapterReviewDtos(int chapterId) + { + return await _context.Chapter + .Where(c => c.Id == chapterId) + .SelectMany(c => c.ExternalReviews) + // Don't use ProjectTo, it fails to map int to float (??) + .Select(r => _mapper.Map(r)) + .ToListAsync(); + } + + public async Task> GetExternalChapterReview(int chapterId) + { + return await _context.Chapter + .Where(c => c.Id == chapterId) + .SelectMany(c => c.ExternalReviews) + .ToListAsync(); + } + + public async Task> GetExternalChapterRatingDtos(int chapterId) + { + return await _context.Chapter + .Where(c => c.Id == chapterId) + .SelectMany(c => c.ExternalRatings) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetExternalChapterRatings(int chapterId) + { + return await _context.Chapter + .Where(c => c.Id == chapterId) + .SelectMany(c => c.ExternalRatings) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/CoverDbRepository.cs b/API/Data/Repositories/CoverDbRepository.cs new file mode 100644 index 000000000..ed13493ab --- /dev/null +++ b/API/Data/Repositories/CoverDbRepository.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using API.DTOs.CoverDb; +using API.Entities; +using API.Entities.Person; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace API.Data.Repositories; +#nullable enable + +/// +/// This is a manual repository, not a DB repo +/// +public class CoverDbRepository +{ + private readonly List _authors; + + public CoverDbRepository(string filePath) + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + // Read and deserialize YAML file + var yamlContent = File.ReadAllText(filePath); + var peopleData = deserializer.Deserialize(yamlContent); + _authors = peopleData.People; + } + + public CoverDbAuthor? FindAuthorByNameOrAlias(string name) + { + return _authors.Find(author => + author.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || + author.Aliases.Contains(name, StringComparer.OrdinalIgnoreCase)); + } + + public CoverDbAuthor? FindBestAuthorMatch(Person person) + { + var aniListId = person.AniListId > 0 ? $"{person.AniListId}" : string.Empty; + var highestScore = 0; + CoverDbAuthor? bestMatch = null; + + foreach (var author in _authors) + { + var score = 0; + + // Check metadata IDs and add points if they match + if (!string.IsNullOrEmpty(author.Ids.AmazonId) && author.Ids.AmazonId == person.Asin) + { + score += 10; + } + if (!string.IsNullOrEmpty(author.Ids.AnilistId) && author.Ids.AnilistId == aniListId) + { + score += 10; + } + if (!string.IsNullOrEmpty(author.Ids.HardcoverId) && author.Ids.HardcoverId == person.HardcoverId) + { + score += 10; + } + + // Check for exact name match + if (author.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase)) + { + score += 7; + } + + // Check for alias match + if (author.Aliases.Contains(person.Name, StringComparer.OrdinalIgnoreCase)) + { + score += 5; + } + + // Update the best match if current score is higher + if (score <= highestScore) continue; + + highestScore = score; + bestMatch = author; + } + + return bestMatch; + } + +} diff --git a/API/Data/Repositories/EmailHistoryRepository.cs b/API/Data/Repositories/EmailHistoryRepository.cs new file mode 100644 index 000000000..e5ed1377a --- /dev/null +++ b/API/Data/Repositories/EmailHistoryRepository.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Email; +using API.Entities; +using API.Helpers; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface IEmailHistoryRepository +{ + Task> GetEmailDtos(UserParams userParams); +} + +public class EmailHistoryRepository : IEmailHistoryRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public EmailHistoryRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + + public async Task> GetEmailDtos(UserParams userParams) + { + return await _context.EmailHistory + .OrderByDescending(h => h.SendDate) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } +} diff --git a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs index 2fabce824..377344a3c 100644 --- a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs +++ b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using API.Constants; using API.DTOs; +using API.DTOs.KavitaPlus.Manage; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -31,14 +32,12 @@ public interface IExternalSeriesMetadataRepository void Remove(IEnumerable? recommendations); void Remove(ExternalSeriesMetadata metadata); Task GetExternalSeriesMetadata(int seriesId); - Task ExternalSeriesMetadataNeedsRefresh(int seriesId); - Task GetSeriesDetailPlusDto(int seriesId); + Task NeedsDataRefresh(int seriesId); + Task GetSeriesDetailPlusDto(int seriesId); Task LinkRecommendationsToSeries(Series series); - Task LinkRecommendationsToSeries(int seriesId); Task IsBlacklistedSeries(int seriesId); - Task CreateBlacklistedSeries(int seriesId, bool saveChanges = true); - Task RemoveFromBlacklist(int seriesId); - Task> GetAllSeriesIdsWithoutMetadata(int limit); + Task> GetSeriesThatNeedExternalMetadata(int limit, bool includeStaleData = false); + Task> GetAllSeries(ManageMatchFilterDto filter); } public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepository @@ -107,16 +106,19 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .FirstOrDefaultAsync(); } - public async Task ExternalSeriesMetadataNeedsRefresh(int seriesId) + public async Task NeedsDataRefresh(int seriesId) { + // TODO: Add unit test var row = await _context.ExternalSeriesMetadata .Where(s => s.SeriesId == seriesId) .FirstOrDefaultAsync(); + return row == null || row.ValidUntilUtc <= DateTime.UtcNow; } - public async Task GetSeriesDetailPlusDto(int seriesId) + public async Task GetSeriesDetailPlusDto(int seriesId) { + // TODO: Add unit test var seriesDetailDto = await _context.ExternalSeriesMetadata .Where(m => m.SeriesId == seriesId) .Include(m => m.ExternalRatings) @@ -145,7 +147,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - IEnumerable reviews = new List(); + IEnumerable reviews = []; if (seriesDetailDto.ExternalReviews != null && seriesDetailDto.ExternalReviews.Any()) { reviews = seriesDetailDto.ExternalReviews @@ -158,8 +160,8 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .OrderByDescending(r => r.Score); } - IEnumerable ratings = new List(); - if (seriesDetailDto.ExternalRatings != null && seriesDetailDto.ExternalRatings.Any()) + IEnumerable ratings = []; + if (seriesDetailDto.ExternalRatings != null && seriesDetailDto.ExternalRatings.Count != 0) { ratings = seriesDetailDto.ExternalRatings .Select(r => _mapper.Map(r)); @@ -180,13 +182,6 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor return seriesDetailPlusDto; } - public async Task LinkRecommendationsToSeries(int seriesId) - { - var series = await _context.Series.Where(s => s.Id == seriesId).AsNoTracking().SingleOrDefaultAsync(); - if (series == null) return; - await LinkRecommendationsToSeries(series); - } - /// /// Searches Recommendations without a SeriesId on record and attempts to link based on Series Name/Localized Name /// @@ -210,55 +205,39 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor public Task IsBlacklistedSeries(int seriesId) { - return _context.SeriesBlacklist.AnyAsync(s => s.SeriesId == seriesId); + return _context.Series + .Where(s => s.Id == seriesId) + .Select(s => s.IsBlacklisted) + .FirstOrDefaultAsync(); } - /// - /// Creates a new instance against SeriesId and Saves to the DB - /// - /// - /// - public async Task CreateBlacklistedSeries(int seriesId, bool saveChanges = true) - { - if (seriesId <= 0 || await _context.SeriesBlacklist.AnyAsync(s => s.SeriesId == seriesId)) return; - await _context.SeriesBlacklist.AddAsync(new SeriesBlacklist() - { - SeriesId = seriesId - }); - if (saveChanges) - { - await _context.SaveChangesAsync(); - } - } - - /// - /// Removes the Series from Blacklist and Saves to the DB - /// - /// - public async Task RemoveFromBlacklist(int seriesId) - { - var seriesBlacklist = await _context.SeriesBlacklist.FirstOrDefaultAsync(sb => sb.SeriesId == seriesId); - - if (seriesBlacklist != null) - { - // Remove the SeriesBlacklist entity from the context - _context.SeriesBlacklist.Remove(seriesBlacklist); - - // Save the changes to the database - await _context.SaveChangesAsync(); - } - } - - public async Task> GetAllSeriesIdsWithoutMetadata(int limit) + public async Task> GetSeriesThatNeedExternalMetadata(int limit, bool includeStaleData = false) { return await _context.Series .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) - .Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow) + .Where(s => s.Library.AllowMetadataMatching) + .WhereIf(includeStaleData, s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow) + .Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.AniListId == 0) + .Where(s => !s.IsBlacklisted && !s.DontMatch) .OrderByDescending(s => s.Library.Type) .ThenBy(s => s.NormalizedName) .Select(s => s.Id) .Take(limit) .ToListAsync(); } + + public async Task> GetAllSeries(ManageMatchFilterDto filter) + { + return await _context.Series + .Include(s => s.Library) + .Include(s => s.ExternalSeriesMetadata) + .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) + .Where(s => s.Library.AllowMetadataMatching) + .WhereIf(filter.LibraryType >= 0, s => s.Library.Type == (LibraryType) filter.LibraryType) + .FilterMatchState(filter.MatchStateOption) + .OrderBy(s => s.NormalizedName) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index f6916e21e..d3baa4de6 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -3,15 +3,18 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IGenreRepository { @@ -21,11 +24,12 @@ public interface IGenreRepository Task> GetAllGenresAsync(); Task> GetAllGenresByNamesAsync(IEnumerable normalizedNames); Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false); - Task> GetAllGenreDtosForLibrariesAsync(int userId, IList? libraryIds = null); + Task> GetAllGenreDtosForLibrariesAsync(int userId, IList? libraryIds = null, QueryContext context = QueryContext.None); Task GetCountAsync(); Task GetRandomGenre(); Task GetGenreById(int id); Task> GetAllGenresNotInListAsync(ICollection genreNames); + Task> GetBrowseableGenre(int userId, UserParams userParams); } public class GenreRepository : IGenreRepository @@ -110,15 +114,15 @@ public class GenreRepository : IGenreRepository /// /// Returns a set of Genre tags for a set of library Ids. - /// UserId will restrict returned Genres based on user's age restriction and library access. + /// AppUserId will restrict returned Genres based on user's age restriction and library access. /// /// /// /// - public async Task> GetAllGenreDtosForLibrariesAsync(int userId, IList? libraryIds = null) + public async Task> GetAllGenreDtosForLibrariesAsync(int userId, IList? libraryIds = null, QueryContext context = QueryContext.None) { var userRating = await _context.AppUser.GetUserAgeRestriction(userId); - var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId, context).ToListAsync(); if (libraryIds is {Count: > 0}) { @@ -164,4 +168,38 @@ public class GenreRepository : IGenreRepository // Return the original non-normalized genres for the missing ones return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); } + + public async Task> GetBrowseableGenre(int userId, UserParams userParams) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = await _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync(); + + var query = _context.Genre + .RestrictAgainstAgeRestriction(ageRating) + .WhereIf(allLibrariesCount != userLibs.Count, + genre => genre.Chapters.Any(cp => seriesIds.Contains(cp.Volume.SeriesId)) || + genre.SeriesMetadatas.Any(sm => seriesIds.Contains(sm.SeriesId))) + .Select(g => new BrowseGenreDto + { + Id = g.Id, + Title = g.Title, + SeriesCount = g.SeriesMetadatas + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count(), + ChapterCount = g.Chapters + .Where(cp => allLibrariesCount == userLibs.Count || seriesIds.Contains(cp.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count(), + }) + .OrderBy(g => g.Title); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } } diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 2ac4f3936..78022fa9a 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -18,6 +18,7 @@ using Kavita.Common.Extensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum LibraryIncludes @@ -39,7 +40,7 @@ public interface ILibraryRepository Task LibraryExists(string libraryName); Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes = LibraryIncludes.None); IEnumerable GetLibraryDtosForUsernameAsync(string userName); - Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None); + Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None, bool track = true); Task> GetLibrariesForUserIdAsync(int userId); IEnumerable GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None); Task GetLibraryTypeAsync(int libraryId); @@ -104,13 +105,16 @@ public class LibraryRepository : ILibraryRepository /// /// /// - public async Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None) + public async Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None, bool track = true) { - return await _context.Library + var query = _context.Library .Include(l => l.AppUsers) .Includes(includes) - .AsSplitQuery() - .ToListAsync(); + .AsSplitQuery(); + + if (track) return await query.ToListAsync(); + + return await query.AsNoTracking().ToListAsync(); } /// @@ -257,7 +261,7 @@ public class LibraryRepository : ILibraryRepository public async Task> GetAllLanguagesForLibrariesAsync(List? libraryIds) { var ret = await _context.Series - .WhereIf(libraryIds is {Count: > 0} , s => libraryIds.Contains(s.LibraryId)) + .WhereIf(libraryIds is {Count: > 0} , s => libraryIds!.Contains(s.LibraryId)) .Select(s => s.Metadata.Language) .AsSplitQuery() .AsNoTracking() diff --git a/API/Data/Repositories/MangaFileRepository.cs b/API/Data/Repositories/MangaFileRepository.cs index debd52199..89c6bb418 100644 --- a/API/Data/Repositories/MangaFileRepository.cs +++ b/API/Data/Repositories/MangaFileRepository.cs @@ -5,11 +5,13 @@ using API.Entities; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IMangaFileRepository { void Update(MangaFile file); Task> GetAllWithMissingExtension(); + Task GetByKoreaderHash(string hash); } public class MangaFileRepository : IMangaFileRepository @@ -32,4 +34,13 @@ public class MangaFileRepository : IMangaFileRepository .Where(f => string.IsNullOrEmpty(f.Extension)) .ToListAsync(); } + + public async Task GetByKoreaderHash(string hash) + { + if (string.IsNullOrEmpty(hash)) return null; + + return await _context.MangaFile + .FirstOrDefaultAsync(f => f.KoreaderHash != null && + f.KoreaderHash.Equals(hash.ToUpper())); + } } diff --git a/API/Data/Repositories/MediaErrorRepository.cs b/API/Data/Repositories/MediaErrorRepository.cs index c2e932d32..40501768e 100644 --- a/API/Data/Repositories/MediaErrorRepository.cs +++ b/API/Data/Repositories/MediaErrorRepository.cs @@ -9,15 +9,18 @@ using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IMediaErrorRepository { void Attach(MediaError error); void Remove(MediaError error); + void Remove(IList errors); Task Find(string filename); IEnumerable GetAllErrorDtosAsync(); Task ExistsAsync(MediaError error); Task DeleteAll(); + Task> GetAllErrorsAsync(IList comments); } public class MediaErrorRepository : IMediaErrorRepository @@ -43,6 +46,11 @@ public class MediaErrorRepository : IMediaErrorRepository _context.MediaError.Remove(error); } + public void Remove(IList errors) + { + _context.MediaError.RemoveRange(errors); + } + public Task Find(string filename) { return _context.MediaError.Where(e => e.FilePath == filename).SingleOrDefaultAsync(); @@ -70,4 +78,11 @@ public class MediaErrorRepository : IMediaErrorRepository _context.MediaError.RemoveRange(await _context.MediaError.ToListAsync()); await _context.SaveChangesAsync(); } + + public Task> GetAllErrorsAsync(IList comments) + { + return _context.MediaError + .Where(m => comments.Contains(m.Comment)) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index a6bb7b271..76ae94735 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -1,19 +1,37 @@ -using System.Collections; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data.Misc; using API.DTOs; -using API.Entities; +using API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; +using API.DTOs.Person; using API.Entities.Enums; +using API.Entities.Person; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Extensions.QueryExtensions.Filtering; using API.Helpers; +using API.Helpers.Converters; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable + +[Flags] +public enum PersonIncludes +{ + None = 1 << 0, + Aliases = 1 << 1, + ChapterPeople = 1 << 2, + SeriesPeople = 1 << 3, + + All = Aliases | ChapterPeople | SeriesPeople, +} public interface IPersonRepository { @@ -24,24 +42,41 @@ public interface IPersonRepository void Remove(SeriesMetadataPeople person); void Update(Person person); - Task> GetAllPeople(); - Task> GetAllPersonDtosAsync(int userId); - Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); + Task> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases); + Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None); + Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None); Task RemoveAllPeopleNoLongerAssociated(); - Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null); - Task GetCountAsync(); + Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null, PersonIncludes includes = PersonIncludes.None); - Task GetCoverImageAsync(int personId); + Task GetCoverImageAsync(int personId); Task GetCoverImageByNameAsync(string name); - Task> GetRolesForPersonByName(string name, int userId); - Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams); - Task GetPersonById(int personId); - Task GetPersonDtoByName(string name, int userId); - Task GetPersonByName(string name); + Task> GetRolesForPersonByName(int personId, int userId); + Task> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams); + Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None); + Task GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases); + /// + /// Returns a person matched on normalized name or alias + /// + /// + /// + /// + Task GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases); + Task IsNameUnique(string name); - Task> GetSeriesKnownFor(int personId); + Task> GetSeriesKnownFor(int personId, int userId); Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); - Task> GetPeopleByNames(List normalizedNames); + /// + /// Returns all people with a matching name, or alias + /// + /// + /// + /// + Task> GetPeopleByNames(List normalizedNames, PersonIncludes includes = PersonIncludes.Aliases); + Task GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases); + + Task> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases); + + Task AnyAliasExist(string alias); } public class PersonRepository : IPersonRepository @@ -100,7 +135,7 @@ public class PersonRepository : IPersonRepository } - public async Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null) + public async Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null, PersonIncludes includes = PersonIncludes.Aliases) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); @@ -114,6 +149,7 @@ public class PersonRepository : IPersonRepository .Where(s => userLibs.Contains(s.LibraryId)) .RestrictAgainstAgeRestriction(ageRating) .SelectMany(s => s.Metadata.People.Select(p => p.Person)) + .Includes(includes) .Distinct() .OrderBy(p => p.Name) .AsNoTracking() @@ -122,12 +158,8 @@ public class PersonRepository : IPersonRepository .ToListAsync(); } - public async Task GetCountAsync() - { - return await _context.Person.CountAsync(); - } - public async Task GetCoverImageAsync(int personId) + public async Task GetCoverImageAsync(int personId) { return await _context.Person .Where(c => c.Id == personId) @@ -135,7 +167,7 @@ public class PersonRepository : IPersonRepository .SingleOrDefaultAsync(); } - public async Task GetCoverImageByNameAsync(string name) + public async Task GetCoverImageByNameAsync(string name) { var normalized = name.ToNormalized(); return await _context.Person @@ -144,79 +176,171 @@ public class PersonRepository : IPersonRepository .SingleOrDefaultAsync(); } - public async Task> GetRolesForPersonByName(string name, int userId) + public async Task> GetRolesForPersonByName(int personId, int userId) { - // TODO: This will need to check both series and chapters (in cases where komf only updates series) - var normalized = name.ToNormalized(); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); - return await _context.Person - .Where(p => p.NormalizedName == normalized) + // Query roles from ChapterPeople + var chapterRoles = await _context.Person + .Where(p => p.Id == personId) + .SelectMany(p => p.ChapterPeople) .RestrictAgainstAgeRestriction(ageRating) - .SelectMany(p => p.ChapterPeople.Select(cp => cp.Role)) + .RestrictByLibrary(userLibs) + .Select(cp => cp.Role) .Distinct() .ToListAsync(); + + // Query roles from SeriesMetadataPeople + var seriesRoles = await _context.Person + .Where(p => p.Id == personId) + .SelectMany(p => p.SeriesMetadataPeople) + .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) + .Select(smp => smp.Role) + .Distinct() + .ToListAsync(); + + // Combine and return distinct roles + return chapterRoles.Union(seriesRoles).Distinct(); } - public async Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams) + public async Task> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams) { - List roles = [PersonRole.Writer, PersonRole.CoverArtist]; var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); - var query = _context.Person - .Where(p => p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))) - .RestrictAgainstAgeRestriction(ageRating) - .Select(p => new BrowsePersonDto - { - Id = p.Id, - Name = p.Name, - Description = p.Description, - SeriesCount = p.SeriesMetadataPeople - .Where(smp => roles.Contains(smp.Role)) - .Select(smp => smp.SeriesMetadata.SeriesId) - .Distinct() - .Count(), - IssueCount = p.ChapterPeople - .Where(cp => roles.Contains(cp.Role)) - .Select(cp => cp.Chapter.Id) - .Distinct() - .Count() - }) - .OrderBy(p => p.Name); + var query = await CreateFilteredPersonQueryable(userId, filter, ageRating); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } - public async Task GetPersonById(int personId) + private async Task> CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating) + { + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = await _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync(); + + var query = _context.Person.AsNoTracking(); + + // Apply filtering based on statements + query = BuildPersonFilterQuery(userId, filter, query); + + // Apply restrictions + query = query.RestrictAgainstAgeRestriction(ageRating) + .WhereIf(allLibrariesCount != userLibs.Count, + person => person.ChapterPeople.Any(cp => seriesIds.Contains(cp.Chapter.Volume.SeriesId)) || + person.SeriesMetadataPeople.Any(smp => seriesIds.Contains(smp.SeriesMetadata.SeriesId))); + + // Apply sorting and limiting + var sortedQuery = query.SortBy(filter.SortOptions); + + var limitedQuery = ApplyPersonLimit(sortedQuery, filter.LimitTo); + + return limitedQuery.Select(p => new BrowsePersonDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + CoverImage = p.CoverImage, + SeriesCount = p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata) + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count(), + ChapterCount = p.ChapterPeople + .Select(chp => chp.Chapter) + .Where(ch => allLibrariesCount == userLibs.Count || seriesIds.Contains(ch.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count(), + }); + } + + private static IQueryable BuildPersonFilterQuery(int userId, BrowsePersonFilterDto filterDto, IQueryable query) + { + if (filterDto.Statements == null || filterDto.Statements.Count == 0) return query; + + var queries = filterDto.Statements + .Select(statement => BuildPersonFilterGroup(userId, statement, query)) + .ToList(); + + return filterDto.Combination == FilterCombination.And + ? queries.Aggregate((q1, q2) => q1.Intersect(q2)) + : queries.Aggregate((q1, q2) => q1.Union(q2)); + } + + private static IQueryable BuildPersonFilterGroup(int userId, PersonFilterStatementDto statement, IQueryable query) + { + var value = PersonFilterFieldValueConverter.ConvertValue(statement.Field, statement.Value); + + return statement.Field switch + { + PersonFilterField.Name => query.HasPersonName(true, statement.Comparison, (string)value), + PersonFilterField.Role => query.HasPersonRole(true, statement.Comparison, (IList)value), + PersonFilterField.SeriesCount => query.HasPersonSeriesCount(true, statement.Comparison, (int)value), + PersonFilterField.ChapterCount => query.HasPersonChapterCount(true, statement.Comparison, (int)value), + _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}") + }; + } + + private static IQueryable ApplyPersonLimit(IQueryable query, int limit) + { + return limit <= 0 ? query : query.Take(limit); + } + + public async Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None) { return await _context.Person.Where(p => p.Id == personId) + .Includes(includes) .FirstOrDefaultAsync(); } - public async Task GetPersonDtoByName(string name, int userId) + public async Task GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases) { var normalized = name.ToNormalized(); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.Person .Where(p => p.NormalizedName == normalized) + .Includes(includes) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) .ProjectTo(_mapper.ConfigurationProvider) .FirstOrDefaultAsync(); } - public async Task GetPersonByName(string name) + public Task GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases) { - return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized()); + var normalized = name.ToNormalized(); + return _context.Person + .Includes(includes) + .Where(p => p.NormalizedName == normalized || p.Aliases.Any(pa => pa.NormalizedAlias == normalized)) + .FirstOrDefaultAsync(); } - public async Task> GetSeriesKnownFor(int personId) + public async Task IsNameUnique(string name) { + // Should this use Normalized to check? + return !(await _context.Person + .Includes(PersonIncludes.Aliases) + .AnyAsync(p => p.Name == name || p.Aliases.Any(pa => pa.Alias == name))); + } + + public async Task> GetSeriesKnownFor(int personId, int userId) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + return await _context.Person .Where(p => p.Id == personId) .SelectMany(p => p.SeriesMetadataPeople) .Select(smp => smp.SeriesMetadata) .Select(sm => sm.Series) + .RestrictAgainstAgeRestriction(ageRating) + .Where(s => userLibs.Contains(s.LibraryId)) .Distinct() .OrderByDescending(s => s.ExternalSeriesMetadata.AverageExternalRating) .Take(20) @@ -227,51 +351,88 @@ public class PersonRepository : IPersonRepository public async Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.ChapterPeople .Where(cp => cp.PersonId == personId && cp.Role == role) .Select(cp => cp.Chapter) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) .OrderBy(ch => ch.SortOrder) .Take(20) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - public async Task> GetPeopleByNames(List normalizedNames) + public async Task> GetPeopleByNames(List normalizedNames, PersonIncludes includes = PersonIncludes.Aliases) { return await _context.Person - .Where(p => normalizedNames.Contains(p.NormalizedName)) + .Includes(includes) + .Where(p => normalizedNames.Contains(p.NormalizedName) || p.Aliases.Any(pa => normalizedNames.Contains(pa.NormalizedAlias))) .OrderBy(p => p.Name) .ToListAsync(); } - public async Task> GetAllPeople() + public async Task GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases) { return await _context.Person - .OrderBy(p => p.Name) - .ToListAsync(); + .Where(p => p.AniListId == aniListId) + .Includes(includes) + .FirstOrDefaultAsync(); } - public async Task> GetAllPersonDtosAsync(int userId) + public async Task> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases) { - var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + searchQuery = searchQuery.ToNormalized(); return await _context.Person - .OrderBy(p => p.Name) - .RestrictAgainstAgeRestriction(ageRating) + .Includes(includes) + .Where(p => EF.Functions.Like(p.NormalizedName, $"%{searchQuery}%") + || p.Aliases.Any(pa => EF.Functions.Like(pa.NormalizedAlias, $"%{searchQuery}%"))) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role) + + public async Task AnyAliasExist(string alias) + { + return await _context.PersonAlias.AnyAsync(pa => pa.NormalizedAlias == alias.ToNormalized()); + } + + + public async Task> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases) + { + return await _context.Person + .Includes(includes) + .OrderBy(p => p.Name) + .ToListAsync(); + } + + public async Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); + + return await _context.Person + .Includes(includes) + .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) + .OrderBy(p => p.Name) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.Person .Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters - .OrderBy(p => p.Name) + .Includes(includes) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) + .OrderBy(p => p.Name) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index ef6cf01fc..6992b2950 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -2,7 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.DTOs; +using API.Data.Misc; +using API.DTOs.Person; using API.DTOs.ReadingLists; using API.Entities; using API.Entities.Enums; @@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum ReadingListIncludes @@ -47,11 +49,15 @@ public interface IReadingListRepository Task> GetRandomCoverImagesAsync(int readingListId); Task> GetAllCoverImagesAsync(); Task ReadingListExists(string name); - IEnumerable GetReadingListCharactersAsync(int readingListId); + Task ReadingListExistsForUser(string name, int userId); + IEnumerable GetReadingListPeopleAsync(int readingListId, PersonRole role); + Task GetReadingListAllPeopleAsync(int readingListId); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task RemoveReadingListsWithoutSeries(); Task GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items); + Task> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items); + Task GetReadingListInfoAsync(int readingListId); } public class ReadingListRepository : IReadingListRepository @@ -104,6 +110,7 @@ public class ReadingListRepository : IReadingListRepository .SelectMany(r => r.Items.Select(ri => ri.Chapter.CoverImage)) .Where(t => !string.IsNullOrEmpty(t)) .ToListAsync(); + return data .OrderBy(_ => random.Next()) .Take(4) @@ -118,12 +125,19 @@ public class ReadingListRepository : IReadingListRepository .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); } - public IEnumerable GetReadingListCharactersAsync(int readingListId) + public async Task ReadingListExistsForUser(string name, int userId) + { + var normalized = name.ToNormalized(); + return await _context.ReadingList + .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized) && x.AppUserId == userId); + } + + public IEnumerable GetReadingListPeopleAsync(int readingListId, PersonRole role) { return _context.ReadingListItem .Where(item => item.ReadingListId == readingListId) .SelectMany(item => item.Chapter.People) - .Where(p => p.Role == PersonRole.Character) + .Where(p => p.Role == role) .OrderBy(p => p.Person.NormalizedName) .Select(p => p.Person) .Distinct() @@ -131,6 +145,77 @@ public class ReadingListRepository : IReadingListRepository .AsEnumerable(); } + public async Task GetReadingListAllPeopleAsync(int readingListId) + { + var allPeople = await _context.ReadingListItem + .Where(item => item.ReadingListId == readingListId) + .SelectMany(item => item.Chapter.People) + .OrderBy(p => p.Person.NormalizedName) + .Select(p => new + { + Role = p.Role, + Person = _mapper.Map(p.Person) + }) + .Distinct() + .ToListAsync(); + + // Create the ReadingListCast object + var cast = new ReadingListCast(); + + // Group people by role and populate the appropriate collections + foreach (var personGroup in allPeople.GroupBy(p => p.Role)) + { + var people = personGroup.Select(pg => pg.Person).ToList(); + + switch (personGroup.Key) + { + case PersonRole.Writer: + cast.Writers = people; + break; + case PersonRole.CoverArtist: + cast.CoverArtists = people; + break; + case PersonRole.Publisher: + cast.Publishers = people; + break; + case PersonRole.Character: + cast.Characters = people; + break; + case PersonRole.Penciller: + cast.Pencillers = people; + break; + case PersonRole.Inker: + cast.Inkers = people; + break; + case PersonRole.Imprint: + cast.Imprints = people; + break; + case PersonRole.Colorist: + cast.Colorists = people; + break; + case PersonRole.Letterer: + cast.Letterers = people; + break; + case PersonRole.Editor: + cast.Editors = people; + break; + case PersonRole.Translator: + cast.Translators = people; + break; + case PersonRole.Team: + cast.Teams = people; + break; + case PersonRole.Location: + cast.Locations = people; + break; + case PersonRole.Other: + break; + } + } + + return cast; + } + public async Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) { var extension = encodeFormat.GetExtension(); @@ -169,7 +254,41 @@ public class ReadingListRepository : IReadingListRepository .AsSplitQuery() .ToListAsync(); } + public async Task> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items) + { + return await _context.ReadingList + .Where(rl => rl.Items.Any(rli => rli.SeriesId == seriesId)) + .Includes(includes) + .AsSplitQuery() + .ToListAsync(); + } + /// + /// Returns a Partial ReadingListInfoDto. The HourEstimate needs to be calculated outside the repo + /// + /// + /// + public async Task GetReadingListInfoAsync(int readingListId) + { + // Get sum of these across all ReadingListItems: long wordCount, int pageCount, bool isEpub (assume false if any ReadingListeItem.Series.Format is non-epub) + var readingList = await _context.ReadingList + .Where(rl => rl.Id == readingListId) + .Include(rl => rl.Items) + .ThenInclude(item => item.Series) + .Include(rl => rl.Items) + .ThenInclude(item => item.Volume) + .Include(rl => rl.Items) + .ThenInclude(item => item.Chapter) + .Select(rl => new ReadingListInfoDto() + { + WordCount = rl.Items.Sum(item => item.Chapter.WordCount), + Pages = rl.Items.Sum(item => item.Chapter.Pages), + IsAllEpub = rl.Items.All(item => item.Series.Format == MangaFormat.Epub), + }) + .FirstOrDefaultAsync(); + + return readingList; + } public void Remove(ReadingListItem item) @@ -185,10 +304,11 @@ public class ReadingListRepository : IReadingListRepository public async Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams, bool sortByLastModified = true) { - var userAgeRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction; + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); var query = _context.ReadingList .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) - .Where(l => l.AgeRating >= userAgeRating); + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()); + query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.NormalizedTitle); var finalQuery = query.ProjectTo(_mapper.ConfigurationProvider) @@ -199,8 +319,10 @@ public class ReadingListRepository : IReadingListRepository public async Task> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId, bool includePromoted) { + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); var query = _context.ReadingList .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()) .Where(l => l.Items.Any(i => i.SeriesId == seriesId)) .AsSplitQuery() .OrderBy(l => l.Title) @@ -212,8 +334,10 @@ public class ReadingListRepository : IReadingListRepository public async Task> GetReadingListDtosForChapterAndUserAsync(int userId, int chapterId, bool includePromoted) { + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); var query = _context.ReadingList .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()) .Where(l => l.Items.Any(i => i.ChapterId == chapterId)) .AsSplitQuery() .OrderBy(l => l.Title) @@ -248,6 +372,7 @@ public class ReadingListRepository : IReadingListRepository ChapterTitleName = chapter.TitleName, FileSize = chapter.Files.Sum(f => f.Bytes), chapter.Summary, + chapter.IsSpecial }) .Join(_context.Volume, s => s.ReadingListItem.VolumeId, volume => volume.Id, (data, volume) => new @@ -259,6 +384,7 @@ public class ReadingListRepository : IReadingListRepository data.ChapterTitleName, data.FileSize, data.Summary, + data.IsSpecial, VolumeId = volume.Id, VolumeNumber = volume.Name, }) @@ -277,6 +403,7 @@ public class ReadingListRepository : IReadingListRepository data.ChapterTitleName, data.FileSize, data.Summary, + data.IsSpecial, LibraryName = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Name).Single(), LibraryType = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Type).Single() }) @@ -299,7 +426,8 @@ public class ReadingListRepository : IReadingListRepository ChapterTitleName = data.ChapterTitleName, LibraryName = data.LibraryName, FileSize = data.FileSize, - Summary = data.Summary + Summary = data.Summary, + IsSpecial = data.IsSpecial }) .Where(o => userLibraries.Contains(o.LibraryId)) .OrderBy(rli => rli.Order) @@ -333,8 +461,10 @@ public class ReadingListRepository : IReadingListRepository public async Task GetReadingListDtoByIdAsync(int readingListId, int userId) { + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); return await _context.ReadingList .Where(r => r.Id == readingListId && (r.AppUserId == userId || r.Promoted)) + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()) .ProjectTo(_mapper.ConfigurationProvider) .SingleOrDefaultAsync(); } diff --git a/API/Data/Repositories/ScrobbleEventRepository.cs b/API/Data/Repositories/ScrobbleEventRepository.cs index 7d3567831..144a3b88e 100644 --- a/API/Data/Repositories/ScrobbleEventRepository.cs +++ b/API/Data/Repositories/ScrobbleEventRepository.cs @@ -12,6 +12,7 @@ using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IScrobbleRepository { @@ -19,16 +20,36 @@ public interface IScrobbleRepository void Attach(ScrobbleError error); void Remove(ScrobbleEvent evt); void Remove(IEnumerable events); + void Remove(IEnumerable errors); void Update(ScrobbleEvent evt); Task> GetByEvent(ScrobbleEventType type, bool isProcessed = false); Task> GetProcessedEvents(int daysAgo); Task Exists(int userId, int seriesId, ScrobbleEventType eventType); Task> GetScrobbleErrors(); + Task> GetAllScrobbleErrorsForSeries(int seriesId); Task ClearScrobbleErrors(); Task HasErrorForSeries(int seriesId); - Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType); + /// + /// Get all events for a specific user and type + /// + /// + /// + /// + /// If true, only returned not processed events + /// + Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType, bool isNotProcessed = false); Task> GetUserEventsForSeries(int userId, int seriesId); + /// + /// Return the events with given ids, when belonging to the passed user + /// + /// + /// + /// + Task> GetUserEvents(int userId, IList scrobbleEventIds); Task> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination); + Task> GetAllEventsForSeries(int seriesId); + Task> GetAllEventsWithSeriesIds(IEnumerable seriesIds); + Task> GetEvents(); } /// @@ -65,6 +86,11 @@ public class ScrobbleRepository : IScrobbleRepository _context.ScrobbleEvent.RemoveRange(events); } + public void Remove(IEnumerable errors) + { + _context.ScrobbleError.RemoveRange(errors); + } + public void Update(ScrobbleEvent evt) { _context.Entry(evt).State = EntityState.Modified; @@ -78,6 +104,7 @@ public class ScrobbleRepository : IScrobbleRepository .Include(s => s.Series) .ThenInclude(s => s.Metadata) .Include(s => s.AppUser) + .ThenInclude(u => u.UserPreferences) .Where(s => s.ScrobbleEventType == type) .Where(s => s.IsProcessed == isProcessed) .AsSplitQuery() @@ -88,6 +115,11 @@ public class ScrobbleRepository : IScrobbleRepository .ToListAsync(); } + /// + /// Returns all processed events that were processed 7 or more days ago + /// + /// + /// public async Task> GetProcessedEvents(int daysAgo) { var date = DateTime.UtcNow.Subtract(TimeSpan.FromDays(daysAgo)); @@ -111,6 +143,13 @@ public class ScrobbleRepository : IScrobbleRepository .ToListAsync(); } + public async Task> GetAllScrobbleErrorsForSeries(int seriesId) + { + return await _context.ScrobbleError + .Where(e => e.SeriesId == seriesId) + .ToListAsync(); + } + public async Task ClearScrobbleErrors() { _context.ScrobbleError.RemoveRange(_context.ScrobbleError); @@ -122,35 +161,66 @@ public class ScrobbleRepository : IScrobbleRepository return await _context.ScrobbleError.AnyAsync(n => n.SeriesId == seriesId); } - public async Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType) + public async Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType, bool isNotProcessed = false) { - return await _context.ScrobbleEvent.FirstOrDefaultAsync(e => - e.AppUserId == userId && e.SeriesId == seriesId && e.ScrobbleEventType == eventType); + return await _context.ScrobbleEvent + .Where(e => e.AppUserId == userId && e.SeriesId == seriesId && e.ScrobbleEventType == eventType) + .WhereIf(isNotProcessed, e => !e.IsProcessed) + .OrderBy(e => e.LastModifiedUtc) + .FirstOrDefaultAsync(); } public async Task> GetUserEventsForSeries(int userId, int seriesId) { return await _context.ScrobbleEvent - .Where(e => e.AppUserId == userId && !e.IsProcessed) + .Where(e => e.AppUserId == userId && !e.IsProcessed && e.SeriesId == seriesId) .Include(e => e.Series) .OrderBy(e => e.LastModifiedUtc) .AsSplitQuery() .ToListAsync(); } + public async Task> GetUserEvents(int userId, IList scrobbleEventIds) + { + return await _context.ScrobbleEvent + .Where(e => e.AppUserId == userId && scrobbleEventIds.Contains(e.Id)) + .ToListAsync(); + } + public async Task> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination) { var query = _context.ScrobbleEvent .Where(e => e.AppUserId == userId) .Include(e => e.Series) - .SortBy(filter.Field, filter.IsDescending) .WhereIf(!string.IsNullOrEmpty(filter.Query), s => EF.Functions.Like(s.Series.Name, $"%{filter.Query}%") ) .WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review) + .SortBy(filter.Field, filter.IsDescending) .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider); return await PagedList.CreateAsync(query, pagination.PageNumber, pagination.PageSize); } + + public async Task> GetAllEventsForSeries(int seriesId) + { + return await _context.ScrobbleEvent + .Where(e => e.SeriesId == seriesId) + .ToListAsync(); + } + + public async Task> GetAllEventsWithSeriesIds(IEnumerable seriesIds) + { + return await _context.ScrobbleEvent + .Where(e => seriesIds.Contains(e.SeriesId)) + .ToListAsync(); + } + + public async Task> GetEvents() + { + return await _context.ScrobbleEvent + .Include(e => e.AppUser) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 741968d3a..0c4b8350a 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -13,8 +13,11 @@ using API.DTOs.CollectionTags; using API.DTOs.Dashboard; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.ReadingLists; +using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.Search; using API.DTOs.SeriesDetail; @@ -38,6 +41,7 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum SeriesIncludes @@ -55,6 +59,8 @@ public enum SeriesIncludes ExternalRatings = 128, ExternalRecommendations = 256, ExternalMetadata = 512, + + ExternalData = ExternalMetadata | ExternalReviews | ExternalRatings | ExternalRecommendations, } /// @@ -65,6 +71,7 @@ public enum QueryContext { None = 1, Search = 2, + [Obsolete("Use Dashboard")] Recommended = 3, Dashboard = 4, } @@ -73,7 +80,9 @@ public interface ISeriesRepository { void Add(Series series); void Attach(Series series); + void Attach(SeriesRelation relation); void Update(Series series); + void Update(SeriesMetadata seriesMetadata); void Remove(Series series); void Remove(IEnumerable series); void Detach(Series series); @@ -144,7 +153,10 @@ public interface ISeriesRepository Task> GetAllSeriesByNameAsync(IList normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); - Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId); + + Task GetSeriesByAnyName(IList names, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None); + Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None); public Task> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format); Task> RemoveSeriesNotInList(IList seenSeries, int libraryId); @@ -162,7 +174,9 @@ public interface ISeriesRepository Task RemoveFromOnDeck(int seriesId, int userId); Task ClearOnDeckRemoval(int seriesId, int userId); Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None); - Task GetPlusSeriesDto(int seriesId); + Task GetPlusSeriesDto(int seriesId); + Task GetCountAsync(); + Task MatchSeries(ExternalSeriesDetailDto externalSeries); } public class SeriesRepository : ISeriesRepository @@ -191,6 +205,11 @@ public class SeriesRepository : ISeriesRepository _context.Series.Attach(series); } + public void Attach(SeriesRelation relation) + { + _context.SeriesRelation.Attach(relation); + } + public void Attach(ExternalSeriesMetadata metadata) { _context.ExternalSeriesMetadata.Attach(metadata); @@ -201,6 +220,11 @@ public class SeriesRepository : ISeriesRepository _context.Entry(series).State = EntityState.Modified; } + public void Update(SeriesMetadata seriesMetadata) + { + _context.Entry(seriesMetadata).State = EntityState.Modified; + } + public void Remove(Series series) { _context.Series.Remove(series); @@ -438,11 +462,18 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Persons = await _context.SeriesMetadata + // I can't work out how to map people in DB layer + var personIds = await _context.SeriesMetadata .SearchPeople(searchQuery, seriesIds) - .Take(maxRecords) - .OrderBy(t => t.NormalizedName) + .Select(p => p.Id) .Distinct() + .OrderBy(id => id) + .Take(maxRecords) + .ToListAsync(); + + result.Persons = await _context.Person + .Where(p => personIds.Contains(p.Id)) + .OrderBy(p => p.NormalizedName) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -458,8 +489,8 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Files = new List(); - result.Chapters = new List(); + result.Files = []; + result.Chapters = (List) []; if (includeChapterAndFiles) @@ -549,7 +580,13 @@ public class SeriesRepository : ISeriesRepository if (!fullSeries) return await query.ToListAsync(); - return await query.Include(s => s.Volumes) + return await query + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.ExternalRatings) + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.ExternalReviews) .Include(s => s.Relations) .Include(s => s.Metadata) @@ -695,25 +732,26 @@ public class SeriesRepository : ISeriesRepository var retSeries = query .ProjectTo(_mapper.ConfigurationProvider) - .AsSplitQuery() .AsNoTracking(); return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); } - public async Task GetPlusSeriesDto(int seriesId) + public async Task GetPlusSeriesDto(int seriesId) { return await _context.Series .Where(s => s.Id == seriesId) - .Select(series => new PlusSeriesDto() + .Include(s => s.ExternalSeriesMetadata) + .Select(series => new PlusSeriesRequestDto() { - MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type), + MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), SeriesName = series.Name, AltSeriesName = series.LocalizedName, AniListId = ScrobblingService.ExtractId(series.Metadata.WebLinks, ScrobblingService.AniListWeblinkWebsite), MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks, ScrobblingService.MalWeblinkWebsite), + CbrId = series.ExternalSeriesMetadata.CbrId, GoogleBooksId = ScrobblingService.ExtractId(series.Metadata.WebLinks, ScrobblingService.GoogleBooksWeblinkWebsite), MangaDexId = ScrobblingService.ExtractId(series.Metadata.WebLinks, @@ -725,6 +763,10 @@ public class SeriesRepository : ISeriesRepository .FirstOrDefaultAsync(); } + public async Task GetCountAsync() + { + return await _context.Series.CountAsync(); + } public async Task AddSeriesModifiers(int userId, IList series) { @@ -1045,8 +1087,6 @@ public class SeriesRepository : ISeriesRepository .Select(u => u.CollapseSeriesRelationships) .SingleOrDefaultAsync(); - - query ??= _context.Series .AsNoTracking(); @@ -1056,20 +1096,18 @@ public class SeriesRepository : ISeriesRepository return query.Where(s => false); } - - // First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here query = ApplyLibraryFilter(filter, query); query = ApplyWantToReadFilter(filter, query, userId); - query = await ApplyCollectionFilter(filter, query, userId, userRating); - query = BuildFilterQuery(userId, filter, query); + query = BuildFilterQuery(userId, filter, query); + query = query .WhereIf(userLibraries.Count > 0, s => userLibraries.Contains(s.LibraryId)) .WhereIf(onlyParentSeries, s => @@ -1080,7 +1118,8 @@ public class SeriesRepository : ISeriesRepository return ApplyLimit(query .Sort(userId, filter.SortOptions) - .AsSplitQuery(), filter.LimitTo); + .AsSplitQuery() + , filter.LimitTo); } private async Task> ApplyCollectionFilter(FilterV2Dto filter, IQueryable query, int userId, AgeRestriction userRating) @@ -1135,6 +1174,7 @@ public class SeriesRepository : ISeriesRepository var seriesIds = _context.AppUser.Where(u => u.Id == userId) .SelectMany(u => u.WantToRead) .Select(s => s.SeriesId); + if (bool.Parse(wantToReadStmt.Value)) { query = query.Where(s => seriesIds.Contains(s.Id)); @@ -1151,6 +1191,7 @@ public class SeriesRepository : ISeriesRepository { var filterIncludeLibs = new List(); var filterExcludeLibs = new List(); + if (filter.Statements != null) { foreach (var stmt in filter.Statements.Where(stmt => stmt.Field == FilterField.Libraries)) @@ -1192,7 +1233,7 @@ public class SeriesRepository : ISeriesRepository private static IQueryable BuildFilterQuery(int userId, FilterV2Dto filterDto, IQueryable query) { - if (filterDto.Statements == null || !filterDto.Statements.Any()) return query; + if (filterDto.Statements == null || filterDto.Statements.Count == 0) return query; var queries = filterDto.Statements @@ -1253,8 +1294,9 @@ public class SeriesRepository : ISeriesRepository FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value), FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value), FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId), + FilterField.ReadLast => query.HasReadLast(true, statement.Comparison, (int) value, userId), FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value), - _ => throw new ArgumentOutOfRangeException() + _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}") }; } @@ -1508,7 +1550,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) + var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard) .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); @@ -1715,24 +1757,71 @@ public class SeriesRepository : ISeriesRepository #nullable enable } - public async Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId) + public async Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None) { var libraryIds = GetLibraryIdsForUser(userId); var normalizedSeries = seriesName.ToNormalized(); var normalizedLocalized = localizedName.ToNormalized(); - return await _context.Series + var query = _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) - .Where(s => formats.Contains(s.Format)) - .Where(s => + .Where(s => formats.Contains(s.Format)); + + if (aniListId.HasValue && aniListId.Value > 0) + { + // If AniList ID is provided, override name checks + query = query.Where(s => s.ExternalSeriesMetadata.AniListId == aniListId.Value); + } + else + { + // Otherwise, use name checks + query = query.Where(s => s.NormalizedName.Equals(normalizedSeries) || s.NormalizedName.Equals(normalizedLocalized) - || s.NormalizedLocalizedName.Equals(normalizedSeries) || (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized)) - || (s.OriginalName != null && s.OriginalName.Equals(seriesName)) - ) + ); + } + + return await query + .Includes(includes) + .FirstOrDefaultAsync(); + } + + + public async Task GetSeriesByAnyName(IList names, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None) + { + var libraryIds = GetLibraryIdsForUser(userId); + names = names.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); + var normalizedNames = names.Select(s => s.ToNormalized()).ToList(); + + + var query = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Where(s => formats.Contains(s.Format)); + + if (aniListId.HasValue && aniListId.Value > 0) + { + // If AniList ID is provided, override name checks + query = query.Where(s => s.ExternalSeriesMetadata.AniListId == aniListId.Value || + normalizedNames.Contains(s.NormalizedName) + || normalizedNames.Contains(s.NormalizedLocalizedName) + || names.Contains(s.OriginalName)); + } + else + { + // Otherwise, use name checks + query = query.Where(s => + normalizedNames.Contains(s.NormalizedName) + || normalizedNames.Contains(s.NormalizedLocalizedName) + || names.Contains(s.OriginalName)); + } + + return await query + .Includes(includes) .FirstOrDefaultAsync(); } @@ -1783,7 +1872,7 @@ public class SeriesRepository : ISeriesRepository .ToList(); // Prefer the first match or handle duplicates by choosing the last one - if (matchingSeries.Any()) + if (matchingSeries.Count != 0) { ids.Add(matchingSeries.Last().Id); } @@ -1991,17 +2080,25 @@ public class SeriesRepository : ISeriesRepository public async Task> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter) { var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); - var query = _context.AppUser + var seriesIds = await _context.AppUser .Where(user => user.Id == userId) .SelectMany(u => u.WantToRead) .Where(s => libraryIds.Contains(s.Series.LibraryId)) - .Select(w => w.Series) + .Select(w => w.Series.Id) + .Distinct() + .ToListAsync(); + + var query = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.None); + + // Apply the Want to Read filtering + query = query.Where(s => seriesIds.Contains(s.Id)); + + var retSeries = query + .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() .AsNoTracking(); - var filteredQuery = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.None, query); - - return await PagedList.CreateAsync(filteredQuery.ProjectTo(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize); + return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); } public async Task> GetWantToReadForUserAsync(int userId) @@ -2021,9 +2118,6 @@ public class SeriesRepository : ISeriesRepository /// Uses multiple names to find a match against a series. If not, returns null. /// /// This does not restrict to the user at all. That is handled at the API level. - /// - /// - /// public async Task GetSeriesDtoByNamesAndMetadataIds(IEnumerable names, LibraryType libraryType, string aniListUrl, string malUrl) { var libraryIds = await _context.Library @@ -2057,6 +2151,47 @@ public class SeriesRepository : ISeriesRepository .FirstOrDefaultAsync(); // Some users may have improperly configured libraries } + public async Task MatchSeries(ExternalSeriesDetailDto externalSeries) + { + var libraryIds = await _context.Library + .Where(lib => externalSeries.PlusMediaFormat.ConvertToLibraryTypes().Contains(lib.Type)) + .Select(l => l.Id) + .ToListAsync(); + + var normalizedNames = (externalSeries.Synonyms ?? Enumerable.Empty()) + .Prepend(externalSeries.Name) + .Select(n => n.ToNormalized()) + .ToList(); + + var aniListWebLink = + ScrobblingService.CreateUrl(ScrobblingService.AniListWeblinkWebsite, externalSeries.AniListId); + var malWebLink = + ScrobblingService.CreateUrl(ScrobblingService.MalWeblinkWebsite, externalSeries.MALId); + + Series? result = null; + if (!string.IsNullOrEmpty(aniListWebLink) || !string.IsNullOrEmpty(malWebLink)) + { + result = await _context.Series + .Where(s => !string.IsNullOrEmpty(s.Metadata.WebLinks)) + .Where(s => libraryIds.Contains(s.Library.Id)) + .WhereIf(!string.IsNullOrEmpty(aniListWebLink), s => s.Metadata.WebLinks.Contains(aniListWebLink)) + .WhereIf(!string.IsNullOrEmpty(malWebLink), s => s.Metadata.WebLinks.Contains(malWebLink)) + .Include(s => s.Metadata) + .AsSplitQuery() + .FirstOrDefaultAsync(); + } + + if (result != null) return result; + + return await _context.Series + .Where(s => normalizedNames.Contains(s.NormalizedName) || + normalizedNames.Contains(s.NormalizedLocalizedName)) + .Where(s => libraryIds.Contains(s.Library.Id)) + .AsSplitQuery() + .Include(s => s.Metadata) + .FirstOrDefaultAsync(); // Some users may have improperly configured libraries + } + /// /// Returns the Average rating for all users within Kavita instance /// diff --git a/API/Data/Repositories/SettingsRepository.cs b/API/Data/Repositories/SettingsRepository.cs index 6d67b36b5..90246e75f 100644 --- a/API/Data/Repositories/SettingsRepository.cs +++ b/API/Data/Repositories/SettingsRepository.cs @@ -1,24 +1,32 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.SeriesDetail; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; using AutoMapper; +using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface ISettingsRepository { void Update(ServerSetting settings); + void Update(MetadataSettings settings); + void RemoveRange(List fieldMappings); Task GetSettingsDtoAsync(); Task GetSettingAsync(ServerSettingKey key); Task> GetSettingsAsync(); void Remove(ServerSetting setting); Task GetExternalSeriesMetadata(int seriesId); + Task GetMetadataSettings(); + Task GetMetadataSettingDto(); } public class SettingsRepository : ISettingsRepository { @@ -36,6 +44,16 @@ public class SettingsRepository : ISettingsRepository _context.Entry(settings).State = EntityState.Modified; } + public void Update(MetadataSettings settings) + { + _context.Entry(settings).State = EntityState.Modified; + } + + public void RemoveRange(List fieldMappings) + { + _context.MetadataFieldMapping.RemoveRange(fieldMappings); + } + public void Remove(ServerSetting setting) { _context.Remove(setting); @@ -48,6 +66,21 @@ public class SettingsRepository : ISettingsRepository .FirstOrDefaultAsync(); } + public async Task GetMetadataSettings() + { + return await _context.MetadataSettings + .Include(m => m.FieldMappings) + .FirstAsync(); + } + + public async Task GetMetadataSettingDto() + { + return await _context.MetadataSettings + .Include(m => m.FieldMappings) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstAsync(); + } + public async Task GetSettingsDtoAsync() { var settings = await _context.ServerSetting diff --git a/API/Data/Repositories/SiteThemeRepository.cs b/API/Data/Repositories/SiteThemeRepository.cs index 2498dfa60..33517e846 100644 --- a/API/Data/Repositories/SiteThemeRepository.cs +++ b/API/Data/Repositories/SiteThemeRepository.cs @@ -8,6 +8,7 @@ using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface ISiteThemeRepository { diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index 4a7fbf4ab..40d40a675 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -2,15 +2,18 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface ITagRepository { @@ -22,6 +25,7 @@ public interface ITagRepository Task RemoveAllTagNoLongerAssociated(); Task> GetAllTagDtosForLibrariesAsync(int userId, IList? libraryIds = null); Task> GetAllTagsNotInListAsync(ICollection tags); + Task> GetBrowseableTag(int userId, UserParams userParams); } public class TagRepository : ITagRepository @@ -103,6 +107,40 @@ public class TagRepository : ITagRepository return missingTags.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); } + public async Task> GetBrowseableTag(int userId, UserParams userParams) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id); + + var query = _context.Tag + .RestrictAgainstAgeRestriction(ageRating) + .WhereIf(userLibs.Count != allLibrariesCount, + tag => tag.Chapters.Any(cp => seriesIds.Contains(cp.Volume.SeriesId)) || + tag.SeriesMetadatas.Any(sm => seriesIds.Contains(sm.SeriesId))) + .Select(g => new BrowseTagDto + { + Id = g.Id, + Title = g.Title, + SeriesCount = g.SeriesMetadatas + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count(), + ChapterCount = g.Chapters + .Where(ch => allLibrariesCount == userLibs.Count || seriesIds.Contains(ch.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) + .Distinct() + .Count() + }) + .OrderBy(g => g.Title); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + public async Task> GetAllTagsAsync() { return await _context.Tag.ToListAsync(); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 07723bf1b..6437cfcfe 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -7,6 +7,7 @@ using API.DTOs; using API.DTOs.Account; using API.DTOs.Dashboard; using API.DTOs.Filtering.v2; +using API.DTOs.KavitaPlus.Account; using API.DTOs.Reader; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -15,12 +16,14 @@ using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions.Filtering; +using API.Helpers; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum AppUserIncludes @@ -39,7 +42,8 @@ public enum AppUserIncludes DashboardStreams = 2048, SideNavStreams = 4096, ExternalSources = 8192, - Collections = 16384 // 2^14 + Collections = 16384, // 2^14 + ChapterRatings = 1 << 15, } public interface IUserRepository @@ -54,13 +58,17 @@ public interface IUserRepository void Delete(AppUser? user); void Delete(AppUserBookmark bookmark); void Delete(IEnumerable streams); + void Delete(AppUserDashboardStream stream); void Delete(IEnumerable streams); + void Delete(AppUserSideNavStream stream); Task> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true); Task> GetAdminUsersAsync(); Task IsUserAdminAsync(AppUser? user); Task> GetRoles(int userId); Task GetUserRatingAsync(int seriesId, int userId); + Task GetUserChapterRatingAsync(int userId, int chapterId); Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId); + Task> GetUserRatingDtosForChapterAsync(int chapterId, int userId); Task GetPreferencesAsync(string username); Task> GetBookmarkDtosForSeries(int userId, int seriesId); Task> GetBookmarkDtosForVolume(int userId, int volumeId); @@ -78,7 +86,7 @@ public interface IUserRepository Task> GetAllPreferencesByThemeAsync(int themeId); Task HasAccessToLibrary(int libraryId, int userId); Task HasAccessToSeries(int userId, int seriesId); - Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None); + Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true); Task GetUserByConfirmationToken(string token); Task GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None); Task> GetSeriesWithRatings(int userId); @@ -92,10 +100,13 @@ public interface IUserRepository Task> GetDashboardStreamWithFilter(int filterId); Task> GetSideNavStreams(int userId, bool visibleOnly = false); Task GetSideNavStream(int streamId); + Task GetSideNavStreamWithUser(int streamId); Task> GetSideNavStreamWithFilter(int filterId); Task> GetSideNavStreamsByLibraryId(int libraryId); Task> GetSideNavStreamWithExternalSource(int externalSourceId); Task> GetDashboardStreamsByIds(IList streamIds); + Task> GetUserTokenInfo(); + Task GetUserByDeviceEmail(string deviceEmail); } public class UserRepository : IUserRepository @@ -162,11 +173,21 @@ public class UserRepository : IUserRepository _context.AppUserDashboardStream.RemoveRange(streams); } + public void Delete(AppUserDashboardStream stream) + { + _context.AppUserDashboardStream.Remove(stream); + } + public void Delete(IEnumerable streams) { _context.AppUserSideNavStream.RemoveRange(streams); } + public void Delete(AppUserSideNavStream stream) + { + _context.AppUserSideNavStream.Remove(stream); + } + /// /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. /// @@ -283,10 +304,17 @@ public class UserRepository : IUserRepository .AnyAsync(s => s.Id == seriesId); } - public async Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None) + public async Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true) { - return await _context.AppUser - .Includes(includeFlags) + var query = _context.AppUser + .Includes(includeFlags); + if (track) + { + return await query.ToListAsync(); + } + + return await query + .AsNoTracking() .ToListAsync(); } @@ -384,6 +412,7 @@ public class UserRepository : IUserRepository .FirstOrDefaultAsync(d => d.Id == streamId); } + public async Task> GetDashboardStreamWithFilter(int filterId) { return await _context.AppUserDashboardStream @@ -420,10 +449,10 @@ public class UserRepository : IUserRepository .Select(d => d.LibraryId) .ToList(); - var libraryDtos = _context.Library + var libraryDtos = await _context.Library .Where(l => libraryIds.Contains(l.Id)) .ProjectTo(_mapper.ConfigurationProvider) - .ToList(); + .ToListAsync(); foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.Library)) { @@ -447,13 +476,21 @@ public class UserRepository : IUserRepository return sideNavStreams; } - public async Task GetSideNavStream(int streamId) + public async Task GetSideNavStream(int streamId) { return await _context.AppUserSideNavStream .Include(d => d.SmartFilter) .FirstOrDefaultAsync(d => d.Id == streamId); } + public async Task GetSideNavStreamWithUser(int streamId) + { + return await _context.AppUserSideNavStream + .Include(d => d.SmartFilter) + .Include(d => d.AppUser) + .FirstOrDefaultAsync(d => d.Id == streamId); + } + public async Task> GetSideNavStreamWithFilter(int filterId) { return await _context.AppUserSideNavStream @@ -483,6 +520,43 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task> GetUserTokenInfo() + { + var users = await _context.AppUser + .Select(u => new + { + u.Id, + u.UserName, + u.AniListAccessToken, // JWT Token + u.MalAccessToken // JWT Token + }) + .ToListAsync(); + + var userTokenInfos = users.Select(user => new UserTokenInfo + { + UserId = user.Id, + Username = user.UserName, + IsAniListTokenSet = !string.IsNullOrEmpty(user.AniListAccessToken), + AniListValidUntilUtc = JwtHelper.GetTokenExpiry(user.AniListAccessToken), + IsAniListTokenValid = JwtHelper.IsTokenValid(user.AniListAccessToken), + IsMalTokenSet = !string.IsNullOrEmpty(user.MalAccessToken), + }); + + return userTokenInfos; + } + + /// + /// Returns the first user with a device email matching + /// + /// + /// + public async Task GetUserByDeviceEmail(string deviceEmail) + { + return await _context.AppUser + .Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail)) + .FirstOrDefaultAsync(); + } + public async Task> GetAdminUsersAsync() { @@ -498,7 +572,16 @@ public class UserRepository : IUserRepository public async Task> GetRoles(int userId) { var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId); - if (user == null || _userManager == null) return ArraySegment.Empty; // userManager is null on Unit Tests only + if (user == null) return ArraySegment.Empty; + + if (_userManager == null) + { + // userManager is null on Unit Tests only + return await _context.UserRoles + .Where(ur => ur.UserId == userId) + .Select(ur => ur.Role.Name) + .ToListAsync(); + } return await _userManager.GetRolesAsync(user); } @@ -507,7 +590,14 @@ public class UserRepository : IUserRepository { return await _context.AppUserRating .Where(r => r.SeriesId == seriesId && r.AppUserId == userId) - .SingleOrDefaultAsync(); + .FirstOrDefaultAsync(); + } + + public async Task GetUserChapterRatingAsync(int userId, int chapterId) + { + return await _context.AppUserChapterRating + .Where(r => r.AppUserId == userId && r.ChapterId == chapterId) + .FirstOrDefaultAsync(); } public async Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId) @@ -523,6 +613,19 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task> GetUserRatingDtosForChapterAsync(int chapterId, int userId) + { + return await _context.AppUserChapterRating + .Include(r => r.AppUser) + .Where(r => r.ChapterId == chapterId) + .Where(r => r.AppUser.UserPreferences.ShareReviews || r.AppUserId == userId) + .OrderBy(r => r.AppUserId == userId) + .ThenBy(r => r.Rating) + .AsSplitQuery() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + public async Task GetPreferencesAsync(string username) { return await _context.AppUserPreferences @@ -654,7 +757,7 @@ public class UserRepository : IUserRepository /// - /// Fetches the UserId by API Key. This does not include any extra information + /// Fetches the AppUserId by API Key. This does not include any extra information /// /// /// diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 0dfbd6393..4b07ade96 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -15,6 +15,7 @@ using Kavita.Common; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum VolumeIncludes @@ -34,6 +35,7 @@ public interface IVolumeRepository void Add(Volume volume); void Update(Volume volume); void Remove(Volume volume); + void Remove(IList volumes); Task> GetFilesForVolume(int volumeId); Task GetVolumeCoverImageAsync(int volumeId); Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); @@ -42,6 +44,7 @@ public interface IVolumeRepository Task GetVolumeDtoAsync(int volumeId, int userId); Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); Task> GetVolumes(int seriesId); + Task> GetVolumesById(IList volumeIds, VolumeIncludes includes = VolumeIncludes.None); Task GetVolumeByIdAsync(int volumeId); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task> GetCoverImagesForLockedVolumesAsync(); @@ -71,6 +74,10 @@ public class VolumeRepository : IVolumeRepository { _context.Volume.Remove(volume); } + public void Remove(IList volumes) + { + _context.Volume.RemoveRange(volumes); + } /// /// Returns a list of non-tracked files for a given volume. @@ -179,6 +186,15 @@ public class VolumeRepository : IVolumeRepository .OrderBy(vol => vol.MinNumber) .ToListAsync(); } + public async Task> GetVolumesById(IList volumeIds, VolumeIncludes includes = VolumeIncludes.None) + { + return await _context.Volume + .Where(vol => volumeIds.Contains(vol.Id)) + .Includes(includes) + .AsSplitQuery() + .OrderBy(vol => vol.MinNumber) + .ToListAsync(); + } /// /// Returns a single volume with Chapter and Files diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 85971558c..c08f80afa 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -10,6 +11,7 @@ using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.Theme; +using API.Entities.MetadataMatching; using API.Extensions; using API.Services; using Kavita.Common; @@ -118,7 +120,7 @@ public static class Seed new AppUserSideNavStream() { Name = "browse-authors", - StreamType = SideNavStreamType.BrowseAuthors, + StreamType = SideNavStreamType.BrowsePeople, Order = 6, IsProvided = true, Visible = true @@ -261,13 +263,12 @@ public static class Seed new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty}, new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"}, new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()}, - new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString()}, - + new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)}, }.ToArray()); foreach (var defaultSetting in DefaultSettings) { - var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); + var existing = await context.ServerSetting.FirstOrDefaultAsync(s => s.Key == defaultSetting.Key); if (existing == null) { await context.ServerSetting.AddAsync(defaultSetting); @@ -277,16 +278,51 @@ public static class Seed await context.SaveChangesAsync(); // Port, IpAddresses and LoggingLevel are managed in appSettings.json. Update the DB values to match - context.ServerSetting.First(s => s.Key == ServerSettingKey.Port).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.Port)).Value = Configuration.Port + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.IpAddresses).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.IpAddresses)).Value = Configuration.IpAddresses; - context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheDirectory)).Value = directoryService.CacheDirectory + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.BackupDirectory)).Value = DirectoryService.BackupDirectory + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheSize).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value = Configuration.CacheSize + string.Empty; + + await context.SaveChangesAsync(); + } + + public static async Task SeedMetadataSettings(DataContext context) + { + await context.Database.EnsureCreatedAsync(); + + var existing = await context.MetadataSettings.FirstOrDefaultAsync(); + if (existing == null) + { + existing = new MetadataSettings() + { + Enabled = true, + EnablePeople = true, + EnableRelationships = true, + EnableSummary = true, + EnablePublicationStatus = true, + EnableStartDate = true, + EnableTags = false, + EnableGenres = true, + EnableLocalizedName = false, + FirstLastPeopleNaming = true, + EnableCoverImage = true, + EnableChapterTitle = false, + EnableChapterSummary = true, + EnableChapterPublisher = true, + EnableChapterCoverImage = false, + EnableChapterReleaseDate = true, + PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character] + }; + await context.MetadataSettings.AddAsync(existing); + } + + await context.SaveChangesAsync(); } diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index e3c1ffcb1..d72dd3bc7 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -32,6 +32,8 @@ public interface IUnitOfWork IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } + IEmailHistoryRepository EmailHistoryRepository { get; } + IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -72,6 +74,8 @@ public class UnitOfWork : IUnitOfWork AppUserSmartFilterRepository = new AppUserSmartFilterRepository(_context, _mapper); AppUserExternalSourceRepository = new AppUserExternalSourceRepository(_context, _mapper); ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper); + EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper); + AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper); } /// @@ -100,6 +104,8 @@ public class UnitOfWork : IUnitOfWork public IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } public IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } + public IEmailHistoryRepository EmailHistoryRepository { get; } + public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; } /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/EmailTemplates/KavitaPlusDebug.html b/API/EmailTemplates/KavitaPlusDebug.html new file mode 100644 index 000000000..e165dfb98 --- /dev/null +++ b/API/EmailTemplates/KavitaPlusDebug.html @@ -0,0 +1,20 @@ + + +

A User needs manual registration

+ + + +
+ + + + + + +
+ + diff --git a/API/EmailTemplates/TokenExpiration.html b/API/EmailTemplates/TokenExpiration.html new file mode 100644 index 000000000..1162dc75b --- /dev/null +++ b/API/EmailTemplates/TokenExpiration.html @@ -0,0 +1,28 @@ + + +

Your {{Provider}} Token is Expired!

+ + + +

Kavita will stop syncing with {{Provider}} until you renew your token.

+ + + + + +
+ + + + + +
+ + + +

If the button above does not work, please find the link here: {{Link}}

+ + diff --git a/API/EmailTemplates/TokenExpiringSoon.html b/API/EmailTemplates/TokenExpiringSoon.html new file mode 100644 index 000000000..960b9b6e5 --- /dev/null +++ b/API/EmailTemplates/TokenExpiringSoon.html @@ -0,0 +1,28 @@ + + +

Your {{Provider}} Token will Expire soon!

+ + + +

Kavita will stop syncing with {{Provider}} until you renew your token.

+ + + + + +
+ + + + + +
+ + + +

If the button above does not work, please find the link here: {{Link}}

+ + diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 2e6f42d3d..848636209 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -19,7 +19,9 @@ public class AppUser : IdentityUser, IHasConcurrencyToken public ICollection UserRoles { get; set; } = null!; public ICollection Progresses { get; set; } = null!; public ICollection Ratings { get; set; } = null!; + public ICollection ChapterRatings { get; set; } = null!; public AppUserPreferences UserPreferences { get; set; } = null!; + public ICollection ReadingProfiles { get; set; } = null!; /// /// Bookmarks associated with this User /// @@ -76,6 +78,18 @@ public class AppUser : IdentityUser, IHasConcurrencyToken ///
public string? MalAccessToken { get; set; } + /// + /// Has the user ran Scrobble Event Generation + /// + /// Only applicable for Kavita+ and when a Token is present + public bool HasRunScrobbleEventGeneration { get; set; } + /// + /// The timestamp of when Scrobble Event Generation ran (Utc) + /// + /// Kavita+ only + public DateTime ScrobbleEventGenerationRan { get; set; } + + /// /// A list of Series the user doesn't want scrobbling for /// diff --git a/API/Entities/AppUserChapterRating.cs b/API/Entities/AppUserChapterRating.cs new file mode 100644 index 000000000..a78096bda --- /dev/null +++ b/API/Entities/AppUserChapterRating.cs @@ -0,0 +1,30 @@ +namespace API.Entities; + +public class AppUserChapterRating +{ + public int Id { get; set; } + /// + /// A number between 0-5.0 that represents how good a series is. + /// + public float Rating { get; set; } + /// + /// If the rating has been explicitly set. Otherwise, the 0.0 rating should be ignored as it's not rated + /// + public bool HasBeenRated { get; set; } + /// + /// A short summary the user can write when giving their review. + /// + public string? Review { get; set; } + /// + /// An optional tagline for the review + /// + public int SeriesId { get; set; } + public Series Series { get; set; } = null!; + + public int ChapterId { get; set; } + public Chapter Chapter { get; set; } = null!; + + // Relationships + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } = null!; +} diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 006d9037c..b0f21bcba 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -1,4 +1,5 @@ -using API.Data; +using System.Collections.Generic; +using API.Data; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; @@ -54,6 +55,10 @@ public class AppUserPreferences /// Manga Reader Option: Should swiping trigger pagination ///
public bool SwipeToPaginate { get; set; } + /// + /// Manga Reader Option: Allow Automatic Webtoon detection + /// + public bool AllowAutomaticWebtoonReaderDetection { get; set; } #endregion @@ -160,7 +165,17 @@ public class AppUserPreferences /// UI Site Global Setting: The language locale that should be used for the user ///
public string Locale { get; set; } + #endregion + #region KavitaPlus + /// + /// Should this account have Scrobbling enabled for AniList + /// + public bool AniListScrobblingEnabled { get; set; } + /// + /// Should this account have Want to Read Sync enabled + /// + public bool WantToReadSync { get; set; } #endregion public AppUser AppUser { get; set; } = null!; diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index edbd25aa7..beaf07220 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -7,7 +7,7 @@ namespace API.Entities; /// /// Represents the progress a single user has on a given Chapter. /// -public class AppUserProgress +public class AppUserProgress : IEntityDate { /// /// Id of Entity diff --git a/API/Entities/AppUserRating.cs b/API/Entities/AppUserRating.cs index 5d66a06e4..e76838926 100644 --- a/API/Entities/AppUserRating.cs +++ b/API/Entities/AppUserRating.cs @@ -26,7 +26,6 @@ public class AppUserRating public int SeriesId { get; set; } public Series Series { get; set; } = null!; - // Relationships public int AppUserId { get; set; } public AppUser AppUser { get; set; } = null!; diff --git a/API/Entities/AppUserReadingProfile.cs b/API/Entities/AppUserReadingProfile.cs new file mode 100644 index 000000000..9b238b4f5 --- /dev/null +++ b/API/Entities/AppUserReadingProfile.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using System.ComponentModel; +using API.Entities.Enums; +using API.Entities.Enums.UserPreferences; + +namespace API.Entities; + +public enum BreakPoint +{ + [Description("Never")] + Never = 0, + [Description("Mobile")] + Mobile = 1, + [Description("Tablet")] + Tablet = 2, + [Description("Desktop")] + Desktop = 3, +} + +public class AppUserReadingProfile +{ + public int Id { get; set; } + + public string Name { get; set; } + public string NormalizedName { get; set; } + + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } + + public ReadingProfileKind Kind { get; set; } + public List LibraryIds { get; set; } + public List SeriesIds { get; set; } + + #region MangaReader + + /// + /// Manga Reader Option: What direction should the next/prev page buttons go + /// + public ReadingDirection ReadingDirection { get; set; } = ReadingDirection.LeftToRight; + /// + /// Manga Reader Option: How should the image be scaled to screen + /// + public ScalingOption ScalingOption { get; set; } = ScalingOption.Automatic; + /// + /// Manga Reader Option: Which side of a split image should we show first + /// + public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.FitSplit; + /// + /// Manga Reader Option: How the manga reader should perform paging or reading of the file + /// + /// Webtoon uses scrolling to page, MANGA_LR uses paging by clicking left/right side of reader, MANGA_UD uses paging + /// by clicking top/bottom sides of reader. + /// + /// + public ReaderMode ReaderMode { get; set; } + /// + /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction + /// + public bool AutoCloseMenu { get; set; } = true; + /// + /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change + /// + public bool ShowScreenHints { get; set; } = true; + /// + /// Manga Reader Option: Emulate a book by applying a shadow effect on the pages + /// + public bool EmulateBook { get; set; } = false; + /// + /// Manga Reader Option: How many pages to display in the reader at once + /// + public LayoutMode LayoutMode { get; set; } = LayoutMode.Single; + /// + /// Manga Reader Option: Background color of the reader + /// + public string BackgroundColor { get; set; } = "#000000"; + /// + /// Manga Reader Option: Should swiping trigger pagination + /// + public bool SwipeToPaginate { get; set; } + /// + /// Manga Reader Option: Allow Automatic Webtoon detection + /// + public bool AllowAutomaticWebtoonReaderDetection { get; set; } + /// + /// Manga Reader Option: Optional fixed width override + /// + public int? WidthOverride { get; set; } = null; + /// + /// Manga Reader Option: Disable the width override if the screen is past the breakpoint + /// + public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never; + + #endregion + + #region EpubReader + + /// + /// Book Reader Option: Override extra Margin + /// + public int BookReaderMargin { get; set; } = 15; + /// + /// Book Reader Option: Override line-height + /// + public int BookReaderLineSpacing { get; set; } = 100; + /// + /// Book Reader Option: Override font size + /// + public int BookReaderFontSize { get; set; } = 100; + /// + /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override + /// + public string BookReaderFontFamily { get; set; } = "default"; + /// + /// Book Reader Option: Allows tapping on side of screens to paginate + /// + public bool BookReaderTapToPaginate { get; set; } = false; + /// + /// Book Reader Option: What direction should the next/prev page buttons go + /// + public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; + /// + /// Book Reader Option: Defines the writing styles vertical/horizontal + /// + public WritingStyle BookReaderWritingStyle { get; set; } = WritingStyle.Horizontal; + /// + /// Book Reader Option: The color theme to decorate the book contents + /// + /// Should default to Dark + public string BookThemeName { get; set; } = "Dark"; + /// + /// Book Reader Option: The way a page from a book is rendered. Default is as book dictates, 1 column is fit to height, + /// 2 column is fit to height, 2 columns + /// + /// Defaults to Default + public BookPageLayoutMode BookReaderLayoutMode { get; set; } = BookPageLayoutMode.Default; + /// + /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. + /// + /// Defaults to false + public bool BookReaderImmersiveMode { get; set; } = false; + #endregion + + #region PdfReader + + /// + /// PDF Reader: Theme of the Reader + /// + public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; + /// + /// PDF Reader: Scroll mode of the reader + /// + public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; + /// + /// PDF Reader: Spread Mode of the reader + /// + public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; + + + #endregion +} diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index 60228ab9f..fe3646943 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -3,12 +3,15 @@ using System.Collections.Generic; using System.Globalization; using API.Entities.Enums; using API.Entities.Interfaces; +using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Extensions; using API.Services.Tasks.Scanner.Parser; namespace API.Entities; -public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage +public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasKPlusMetadata { public int Id { get; set; } /// @@ -124,6 +127,16 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage public string WebLinks { get; set; } = string.Empty; public string ISBN { get; set; } = string.Empty; + /// + /// Tracks which metadata has been set by K+ + /// + public IList KPlusOverrides { get; set; } = []; + + /// + /// (Kavita+) Average rating from Kavita+ metadata + /// + public float AverageExternalRating { get; set; } = 0f; + #region Locks public bool AgeRatingLocked { get; set; } @@ -159,6 +172,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage /// public ICollection Genres { get; set; } = new List(); public ICollection Tags { get; set; } = new List(); + public ICollection Ratings { get; set; } = []; public ICollection UserProgress { get; set; } @@ -167,6 +181,9 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage public Volume Volume { get; set; } = null!; public int VolumeId { get; set; } + public ICollection ExternalReviews { get; set; } = []; + public ICollection ExternalRatings { get; set; } = null!; + public void UpdateFrom(ParserInfo info) { Files ??= new List(); @@ -177,8 +194,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage MinNumber = Parser.DefaultChapterNumber; MaxNumber = Parser.DefaultChapterNumber; } - // NOTE: This doesn't work well for all because Pdf usually should use into.Title or even filename - Title = (IsSpecial && info.Format == MangaFormat.Epub) + Title = (IsSpecial && info.Format is MangaFormat.Epub or MangaFormat.Pdf) ? info.Title : Parser.RemoveExtensionIfSupported(Range); @@ -192,22 +208,30 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage /// public string GetNumberTitle() { - if (MinNumber.Is(MaxNumber)) + try { - if (MinNumber.Is(Parser.DefaultChapterNumber) && IsSpecial) + if (MinNumber.Is(MaxNumber)) { - return Parser.RemoveExtensionIfSupported(Title); + if (MinNumber.Is(Parser.DefaultChapterNumber) && IsSpecial) + { + return Parser.RemoveExtensionIfSupported(Title); + } + + if (MinNumber.Is(0f) && !float.TryParse(Range, CultureInfo.InvariantCulture, out _)) + { + return $"{Range.ToString(CultureInfo.InvariantCulture)}"; + } + + return $"{MinNumber.ToString(CultureInfo.InvariantCulture)}"; + } - if (MinNumber.Is(0) && !float.TryParse(Range, CultureInfo.InvariantCulture, out _)) - { - return $"{Range}"; - } - - return $"{MinNumber}"; - + return $"{MinNumber.ToString(CultureInfo.InvariantCulture)}-{MaxNumber.ToString(CultureInfo.InvariantCulture)}"; + } + catch (Exception) + { + return MinNumber.ToString(CultureInfo.InvariantCulture); } - return $"{MinNumber}-{MaxNumber}"; } /// @@ -224,4 +248,25 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage PrimaryColor = string.Empty; SecondaryColor = string.Empty; } + + public bool IsPersonRoleLocked(PersonRole role) + { + return role switch + { + PersonRole.Character => CharacterLocked, + PersonRole.Writer => WriterLocked, + PersonRole.Penciller => PencillerLocked, + PersonRole.Inker => InkerLocked, + PersonRole.Colorist => ColoristLocked, + PersonRole.Letterer => LettererLocked, + PersonRole.CoverArtist => CoverArtistLocked, + PersonRole.Editor => EditorLocked, + PersonRole.Publisher => PublisherLocked, + PersonRole.Translator => TranslatorLocked, + PersonRole.Imprint => ImprintLocked, + PersonRole.Team => TeamLocked, + PersonRole.Location => LocationLocked, + _ => throw new ArgumentOutOfRangeException(nameof(role), role, null) + }; + } } diff --git a/API/Entities/EmailHistory.cs b/API/Entities/EmailHistory.cs new file mode 100644 index 000000000..f1ab95ca5 --- /dev/null +++ b/API/Entities/EmailHistory.cs @@ -0,0 +1,31 @@ +using System; +using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities; + +/// +/// Records all emails that are sent from Kavita +/// +[Index("Sent", "AppUserId", "EmailTemplate", "SendDate")] +public class EmailHistory : IEntityDate +{ + public long Id { get; set; } + public bool Sent { get; set; } + public DateTime SendDate { get; set; } = DateTime.UtcNow; + public string EmailTemplate { get; set; } + public string Subject { get; set; } + public string Body { get; set; } + + public string DeliveryStatus { get; set; } + public string ErrorMessage { get; set; } + + public int AppUserId { get; set; } + public virtual AppUser AppUser { get; set; } + + + public DateTime Created { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModified { get; set; } + public DateTime LastModifiedUtc { get; set; } +} diff --git a/API/Entities/Enums/FileTypeGroup.cs b/API/Entities/Enums/FileTypeGroup.cs index 3d33aa37c..eda039fc9 100644 --- a/API/Entities/Enums/FileTypeGroup.cs +++ b/API/Entities/Enums/FileTypeGroup.cs @@ -15,5 +15,4 @@ public enum FileTypeGroup Pdf = 3, [Description("Images")] Images = 4 - } diff --git a/API/Entities/Enums/LibraryType.cs b/API/Entities/Enums/LibraryType.cs index 40d1b10a8..a8d943b2d 100644 --- a/API/Entities/Enums/LibraryType.cs +++ b/API/Entities/Enums/LibraryType.cs @@ -12,7 +12,7 @@ public enum LibraryType /// /// Uses Comic regex for filename parsing /// - [Description("Comic")] + [Description("Comic (Legacy)")] Comic = 1, /// /// Uses Manga regex for filename parsing also uses epub metadata @@ -30,8 +30,8 @@ public enum LibraryType [Description("Light Novel")] LightNovel = 4, /// - /// Uses Comic regex for filename parsing, uses Comic Vine type of Parsing. Will replace Comic type in future + /// Uses Comic regex for filename parsing, uses Comic Vine type of Parsing /// - [Description("Comic (Comic Vine)")] + [Description("Comic")] ComicVine = 5, } diff --git a/API/Entities/Enums/MetadataFieldType.cs b/API/Entities/Enums/MetadataFieldType.cs new file mode 100644 index 000000000..0052b6599 --- /dev/null +++ b/API/Entities/Enums/MetadataFieldType.cs @@ -0,0 +1,7 @@ +namespace API.Entities.Enums; + +public enum MetadataFieldType +{ + Genre = 0, + Tag = 1, +} diff --git a/API/Entities/Enums/RatingAuthority.cs b/API/Entities/Enums/RatingAuthority.cs new file mode 100644 index 000000000..0f358a9a7 --- /dev/null +++ b/API/Entities/Enums/RatingAuthority.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +public enum RatingAuthority +{ + /// + /// Rating was from a User (internet or local) + /// + [Description("User")] + User = 0, + /// + /// Rating was from Professional Critics + /// + [Description("Critic")] + Critic = 1, +} diff --git a/API/Entities/Enums/ReadingProfileKind.cs b/API/Entities/Enums/ReadingProfileKind.cs new file mode 100644 index 000000000..0f9cfa20b --- /dev/null +++ b/API/Entities/Enums/ReadingProfileKind.cs @@ -0,0 +1,17 @@ +namespace API.Entities.Enums; + +public enum ReadingProfileKind +{ + /// + /// Generate by Kavita when registering a user, this is your default profile + /// + Default, + /// + /// Created by the user in the UI or via the API + /// + User, + /// + /// Automatically generated by Kavita to track changes made in the readers. Can be converted to a User Reading Profile. + /// + Implicit +} diff --git a/API/Entities/History/KavitaPlusHistory.cs b/API/Entities/History/KavitaPlusHistory.cs new file mode 100644 index 000000000..81b7e5e40 --- /dev/null +++ b/API/Entities/History/KavitaPlusHistory.cs @@ -0,0 +1,9 @@ +namespace API.Entities.History; + +/// +/// Records history of actions Kavita+ takes +/// +// public class KavitaPlusHistory +// { +// +// } diff --git a/API/Entities/ManualMigrationHistory.cs b/API/Entities/History/ManualMigrationHistory.cs similarity index 91% rename from API/Entities/ManualMigrationHistory.cs rename to API/Entities/History/ManualMigrationHistory.cs index e65e07b2c..2f407ca1d 100644 --- a/API/Entities/ManualMigrationHistory.cs +++ b/API/Entities/History/ManualMigrationHistory.cs @@ -1,6 +1,6 @@ using System; -namespace API.Entities; +namespace API.Entities.History; /// /// This will track manual migrations so that I can use simple selects to check if a Manual Migration is needed diff --git a/API/Entities/Interfaces/IHasKPlusMetadata.cs b/API/Entities/Interfaces/IHasKPlusMetadata.cs new file mode 100644 index 000000000..062afd7e1 --- /dev/null +++ b/API/Entities/Interfaces/IHasKPlusMetadata.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using API.Entities.MetadataMatching; + +namespace API.Entities.Interfaces; + +public interface IHasKPlusMetadata +{ + /// + /// Tracks which metadata has been set by K+ + /// + public IList KPlusOverrides { get; set; } +} diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index 7a413e3f4..4a48fed99 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -40,11 +40,22 @@ public class Library : IEntityDate, IHasCoverImage /// /// Should this library allow Scrobble events to emit from it /// - /// Scrobbling requires a valid LicenseKey + /// Requires a valid LicenseKey public bool AllowScrobbling { get; set; } = true; - - - + /// + /// Allow any series within this Library to download metadata. + /// + /// This does not exclude the library from being linked to wrt Series Relationships + /// Requires a valid LicenseKey + public bool AllowMetadataMatching { get; set; } = true; + /// + /// Should Kavita read metadata files from the library + /// + public bool EnableMetadata { get; set; } = true; + /// + /// Should Kavita remove sort articles "The" for the sort name + /// + public bool RemovePrefixForSortName { get; set; } = false; public DateTime Created { get; set; } diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index f104f4c72..afcb23e97 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -21,6 +21,11 @@ public class MangaFile : IEntityDate /// public required string FilePath { get; set; } /// + /// A hash of the document using Koreader's unique hashing algorithm + /// + /// KoreaderHash is only available for epub types + public string? KoreaderHash { get; set; } + /// /// Number of pages for the given file /// public int Pages { get; set; } diff --git a/API/Entities/Metadata/ExternalRating.cs b/API/Entities/Metadata/ExternalRating.cs index b325353e4..7fc2b9353 100644 --- a/API/Entities/Metadata/ExternalRating.cs +++ b/API/Entities/Metadata/ExternalRating.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.Entities.Enums; using API.Services.Plus; namespace API.Entities.Metadata; @@ -10,8 +11,16 @@ public class ExternalRating public int AverageScore { get; set; } public int FavoriteCount { get; set; } public ScrobbleProvider Provider { get; set; } + /// + /// Where this rating comes from: Critic or User + /// + public RatingAuthority Authority { get; set; } = RatingAuthority.User; public string? ProviderUrl { get; set; } public int SeriesId { get; set; } + /// + /// This can be null when for a series-rating + /// + public int? ChapterId { get; set; } public ICollection ExternalSeriesMetadatas { get; set; } = null!; } diff --git a/API/Entities/Metadata/ExternalReview.cs b/API/Entities/Metadata/ExternalReview.cs index 6304d98ad..73c71e5ee 100644 --- a/API/Entities/Metadata/ExternalReview.cs +++ b/API/Entities/Metadata/ExternalReview.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.Entities.Enums; using API.Services.Plus; namespace API.Entities.Metadata; @@ -20,6 +21,7 @@ public class ExternalReview /// public string RawBody { get; set; } public required ScrobbleProvider Provider { get; set; } + public RatingAuthority Authority { get; set; } = RatingAuthority.User; public string SiteUrl { get; set; } /// /// Reviewer's username @@ -37,6 +39,7 @@ public class ExternalReview public int SeriesId { get; set; } + public int? ChapterId { get; set; } // Relationships public ICollection ExternalSeriesMetadatas { get; set; } = null!; diff --git a/API/Entities/Metadata/ExternalSeriesMetadata.cs b/API/Entities/Metadata/ExternalSeriesMetadata.cs index 215a01585..1ab37ba3c 100644 --- a/API/Entities/Metadata/ExternalSeriesMetadata.cs +++ b/API/Entities/Metadata/ExternalSeriesMetadata.cs @@ -21,11 +21,12 @@ public class ExternalSeriesMetadata public ICollection ExternalRecommendations { get; set; } = null!; /// - /// Average External Rating. -1 means not set + /// Average External Rating. -1 means not set, 0 - 100 /// - public int AverageExternalRating { get; set; } = 0; + public int AverageExternalRating { get; set; } = -1; public int AniListId { get; set; } + public int CbrId { get; set; } public long MalId { get; set; } public string GoogleBooksId { get; set; } diff --git a/API/Entities/Metadata/SeriesBlacklist.cs b/API/Entities/Metadata/SeriesBlacklist.cs index 09ff06153..3d262eeb4 100644 --- a/API/Entities/Metadata/SeriesBlacklist.cs +++ b/API/Entities/Metadata/SeriesBlacklist.cs @@ -5,10 +5,12 @@ namespace API.Entities.Metadata; /// /// A blacklist of Series for Kavita+ /// +[Obsolete("Kavita v0.8.5 moved the implementation to Series.IsBlacklisted")] public class SeriesBlacklist { public int Id { get; set; } + public DateTime LastChecked { get; set; } = DateTime.UtcNow; + public int SeriesId { get; set; } public Series Series { get; set; } - public DateTime LastChecked { get; set; } = DateTime.UtcNow; } diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 6e594aa73..8bb33fdc0 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -1,14 +1,17 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using API.Entities.Enums; using API.Entities.Interfaces; +using API.Entities.MetadataMatching; +using API.Entities.Person; using Microsoft.EntityFrameworkCore; namespace API.Entities.Metadata; [Index(nameof(Id), nameof(SeriesId), IsUnique = true)] -public class SeriesMetadata : IHasConcurrencyToken +public class SeriesMetadata : IHasConcurrencyToken, IHasKPlusMetadata { public int Id { get; set; } @@ -40,6 +43,10 @@ public class SeriesMetadata : IHasConcurrencyToken /// /// This is not populated from Chapters of the Series public string WebLinks { get; set; } = string.Empty; + /// + /// Tracks which metadata has been set by K+ + /// + public IList KPlusOverrides { get; set; } = []; #region Locks @@ -101,4 +108,26 @@ public class SeriesMetadata : IHasConcurrencyToken { RowVersion++; } + + /// + /// Any People in this Role present + /// + /// + /// + public bool AnyOfRole(PersonRole role) + { + return People.Any(p => p.Role == role); + } + + /// + /// Are all instances of the role from Kavita+ + /// + /// + /// + public bool AllKavitaPlus(PersonRole role) + { + var people = People.Where(p => p.Role == role); + if (people.Any()) return people.All(p => p.KavitaPlusConnection); + return false; + } } diff --git a/API/Entities/MetadataMatching/MetadataFieldMapping.cs b/API/Entities/MetadataMatching/MetadataFieldMapping.cs new file mode 100644 index 000000000..e7dd88c03 --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataFieldMapping.cs @@ -0,0 +1,26 @@ +using API.Entities.Enums; +using API.Entities.MetadataMatching; + +namespace API.Entities; + +public class MetadataFieldMapping +{ + public int Id { get; set; } + public MetadataFieldType SourceType { get; set; } + public MetadataFieldType DestinationType { get; set; } + /// + /// The string in the source + /// + public string SourceValue { get; set; } + /// + /// Write the string as this in the Destination (can also just be the Source) + /// + public string DestinationValue { get; set; } + /// + /// If true, the tag will be Moved over vs Copied over + /// + public bool ExcludeFromSource { get; set; } + + public int MetadataSettingsId { get; set; } + public virtual MetadataSettings MetadataSettings { get; set; } +} diff --git a/API/Entities/MetadataMatching/MetadataSettingField.cs b/API/Entities/MetadataMatching/MetadataSettingField.cs new file mode 100644 index 000000000..9333c269e --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataSettingField.cs @@ -0,0 +1,31 @@ +namespace API.Entities.MetadataMatching; + +/// +/// Represents which field that can be written to as an override when already locked +/// +public enum MetadataSettingField +{ + #region Series Metadata + Summary = 1, + PublicationStatus = 2, + StartDate = 3, + Genres = 4, + Tags = 5, + LocalizedName = 6, + Covers = 7, + AgeRating = 8, + People = 9, + #endregion + + #region Chapter Metadata + + ChapterTitle = 10, + ChapterSummary = 11, + ChapterReleaseDate = 12, + ChapterPublisher = 13, + ChapterCovers = 14, + + #endregion + + +} diff --git a/API/Entities/MetadataMatching/MetadataSettings.cs b/API/Entities/MetadataMatching/MetadataSettings.cs new file mode 100644 index 000000000..aeb44b619 --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataSettings.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.Entities.MetadataMatching; + +/// +/// Handles the metadata settings for Kavita+ +/// +public class MetadataSettings +{ + public int Id { get; set; } + /// + /// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed + /// + public bool Enabled { get; set; } + + #region Series Metadata + + /// + /// Allow the Summary to be written + /// + public bool EnableSummary { get; set; } + /// + /// Allow Publication status to be derived and updated + /// + public bool EnablePublicationStatus { get; set; } + /// + /// Allow Relationships between series to be set + /// + public bool EnableRelationships { get; set; } + /// + /// Allow People to be created (including downloading images) + /// + public bool EnablePeople { get; set; } + /// + /// Allow Start date to be set within the Series + /// + public bool EnableStartDate { get; set; } + /// + /// Allow setting the Localized name + /// + public bool EnableLocalizedName { get; set; } + /// + /// Allow setting the cover image + /// + public bool EnableCoverImage { get; set; } + #endregion + + #region Chapter Metadata + /// + /// Allow Summary to be set within Chapter/Issue + /// + public bool EnableChapterSummary { get; set; } + /// + /// Allow Release Date to be set within Chapter/Issue + /// + public bool EnableChapterReleaseDate { get; set; } + /// + /// Allow Title to be set within Chapter/Issue + /// + public bool EnableChapterTitle { get; set; } + /// + /// Allow Publisher to be set within Chapter/Issue + /// + public bool EnableChapterPublisher { get; set; } + /// + /// Allow setting the cover image for the Chapter/Issue + /// + public bool EnableChapterCoverImage { get; set; } + #endregion + + // Need to handle the Genre/tags stuff + public bool EnableGenres { get; set; } = true; + public bool EnableTags { get; set; } = true; + + /// + /// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names. + /// + public bool FirstLastPeopleNaming { get; set; } + + /// + /// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching. + /// + public Dictionary AgeRatingMappings { get; set; } + + /// + /// A list of rules that allow mapping a genre/tag to another genre/tag + /// + public List FieldMappings { get; set; } + + /// + /// A list of overrides that will enable writing to locked fields + /// + public List Overrides { get; set; } + + /// + /// Do not allow any Genre/Tag in this list to be written to Kavita + /// + public List Blacklist { get; set; } + + /// + /// Only allow these Tags to be written to Kavita + /// + public List Whitelist { get; set; } + + /// + /// Which Roles to allow metadata downloading for + /// + public List PersonRoles { get; set; } +} diff --git a/API/Entities/Person/ChapterPeople.cs b/API/Entities/Person/ChapterPeople.cs index cc0802782..c6a08a7dd 100644 --- a/API/Entities/Person/ChapterPeople.cs +++ b/API/Entities/Person/ChapterPeople.cs @@ -1,6 +1,6 @@ using API.Entities.Enums; -namespace API.Entities; +namespace API.Entities.Person; public class ChapterPeople { @@ -10,5 +10,14 @@ public class ChapterPeople public int PersonId { get; set; } public virtual Person Person { get; set; } + /// + /// The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches + /// + public bool KavitaPlusConnection { get; set; } + /// + /// A weight that allows lower numbers to sort first + /// + public int OrderWeight { get; set; } + public required PersonRole Role { get; set; } } diff --git a/API/Entities/Person/Person.cs b/API/Entities/Person/Person.cs index ba40b5f82..ed57fd6d3 100644 --- a/API/Entities/Person/Person.cs +++ b/API/Entities/Person/Person.cs @@ -1,46 +1,44 @@ using System.Collections.Generic; -using API.Entities.Enums; using API.Entities.Interfaces; -using API.Entities.Metadata; -namespace API.Entities; +namespace API.Entities.Person; public class Person : IHasCoverImage { public int Id { get; set; } public required string Name { get; set; } public required string NormalizedName { get; set; } - - //public ICollection Aliases { get; set; } = default!; + public ICollection Aliases { get; set; } = []; public string? CoverImage { get; set; } public bool CoverImageLocked { get; set; } public string PrimaryColor { get; set; } public string SecondaryColor { get; set; } - public string Description { get; set; } - /// - /// ASIN for person - /// - /// Can be used for Amazon author lookup - public string? Asin { get; set; } + public string Description { get; set; } + /// + /// ASIN for person + /// + /// Can be used for Amazon author lookup + public string? Asin { get; set; } + + /// + /// https://anilist.co/staff/{AniListId}/ + /// + /// Kavita+ Only + public int AniListId { get; set; } = 0; + /// + /// https://myanimelist.net/people/{MalId}/ + /// https://myanimelist.net/character/{MalId}/CharacterName + /// + /// Kavita+ Only + public long MalId { get; set; } = 0; + /// + /// https://hardcover.app/authors/{HardcoverId} + /// + /// Kavita+ Only + public string? HardcoverId { get; set; } - /// - /// https://anilist.co/staff/{AniListId}/ - /// - /// Kavita+ Only - public int AniListId { get; set; } = 0; - /// - /// https://myanimelist.net/people/{MalId}/ - /// https://myanimelist.net/character/{MalId}/CharacterName - /// - /// Kavita+ Only - public long MalId { get; set; } = 0; - /// - /// https://hardcover.app/authors/{HardcoverId} - /// - /// Kavita+ Only - public string? HardcoverId { get; set; } /// /// https://metron.cloud/creator/{slug}/ /// @@ -48,8 +46,8 @@ public class Person : IHasCoverImage //public long MetronId { get; set; } = 0; // Relationships - public ICollection ChapterPeople { get; set; } = new List(); - public ICollection SeriesMetadataPeople { get; set; } = new List(); + public ICollection ChapterPeople { get; set; } = []; + public ICollection SeriesMetadataPeople { get; set; } = []; public void ResetColorScape() diff --git a/API/Entities/Person/PersonAlias.cs b/API/Entities/Person/PersonAlias.cs new file mode 100644 index 000000000..f053f608d --- /dev/null +++ b/API/Entities/Person/PersonAlias.cs @@ -0,0 +1,11 @@ +namespace API.Entities.Person; + +public class PersonAlias +{ + public int Id { get; set; } + public required string Alias { get; set; } + public required string NormalizedAlias { get; set; } + + public int PersonId { get; set; } + public Person Person { get; set; } +} diff --git a/API/Entities/Person/SeriesMetadataPeople.cs b/API/Entities/Person/SeriesMetadataPeople.cs index dd188ddf0..caea10cd6 100644 --- a/API/Entities/Person/SeriesMetadataPeople.cs +++ b/API/Entities/Person/SeriesMetadataPeople.cs @@ -1,7 +1,7 @@ using API.Entities.Enums; using API.Entities.Metadata; -namespace API.Entities; +namespace API.Entities.Person; public class SeriesMetadataPeople { @@ -11,5 +11,14 @@ public class SeriesMetadataPeople public int PersonId { get; set; } public virtual Person Person { get; set; } + /// + /// The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches + /// + public bool KavitaPlusConnection { get; set; } = false; + /// + /// A weight that allows lower numbers to sort first + /// + public int OrderWeight { get; set; } + public required PersonRole Role { get; set; } } diff --git a/API/Entities/Scrobble/ScrobbleEvent.cs b/API/Entities/Scrobble/ScrobbleEvent.cs index a02363992..8adfdcc2e 100644 --- a/API/Entities/Scrobble/ScrobbleEvent.cs +++ b/API/Entities/Scrobble/ScrobbleEvent.cs @@ -28,7 +28,7 @@ public class ScrobbleEvent : IEntityDate /// public string? ReviewBody { get; set; } public string? ReviewTitle { get; set; } - public required MediaFormat Format { get; set; } + public required PlusMediaFormat Format { get; set; } /// /// Depends on the ScrobbleEvent if filled in /// @@ -68,4 +68,14 @@ public class ScrobbleEvent : IEntityDate public DateTime LastModified { get; set; } public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } + + /// + /// Sets the ErrorDetail and marks the event as + /// + /// + public void SetErrorMessage(string errorMessage) + { + ErrorDetails = errorMessage; + IsErrored = true; + } } diff --git a/API/Entities/Scrobble/ScrobbleEventSortField.cs b/API/Entities/Scrobble/ScrobbleEventSortField.cs index 729ac7fbe..51b3a2146 100644 --- a/API/Entities/Scrobble/ScrobbleEventSortField.cs +++ b/API/Entities/Scrobble/ScrobbleEventSortField.cs @@ -7,5 +7,6 @@ public enum ScrobbleEventSortField LastModified = 2, Type= 3, Series = 4, - IsProcessed = 5 + IsProcessed = 5, + ScrobbleEventFilter = 6 } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index c467ee076..4f06ab0fc 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -103,6 +103,17 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage public int MaxHoursToRead { get; set; } public float AvgHoursToRead { get; set; } + #region KavitaPlus + /// + /// Do not match the series with any external Metadata service. This will automatically opt it out of scrobbling. + /// + public bool DontMatch { get; set; } + /// + /// If the series was unable to match, it will be blacklisted until a manual metadata match overrides it + /// + public bool IsBlacklisted { get; set; } + #endregion + public SeriesMetadata Metadata { get; set; } = null!; public ExternalSeriesMetadata ExternalSeriesMetadata { get; set; } = null!; @@ -151,4 +162,14 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage PrimaryColor = string.Empty; SecondaryColor = string.Empty; } + + /// + /// Is this Series capable of Scrobbling + /// + /// This includes if there is no Match/Manual Match needed, the series is blacklisted, or has a NoMatch + /// + public bool WillScrobble() + { + return !IsBlacklisted && !DontMatch; + } } diff --git a/API/Entities/SideNavStreamType.cs b/API/Entities/SideNavStreamType.cs index 545c630d8..62f429889 100644 --- a/API/Entities/SideNavStreamType.cs +++ b/API/Entities/SideNavStreamType.cs @@ -10,5 +10,5 @@ public enum SideNavStreamType ExternalSource = 6, AllSeries = 7, WantToRead = 8, - BrowseAuthors = 9 + BrowsePeople = 9 } diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index a5badccf7..5338494e6 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using API.Entities.Interfaces; using API.Extensions; using API.Services.Tasks.Scanner.Parser; @@ -67,11 +68,12 @@ public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage /// public string GetNumberTitle() { - if (MinNumber.Is(MaxNumber)) + if (MinNumber.Equals(MaxNumber)) { - return $"{MinNumber}"; + return MinNumber.ToString(CultureInfo.InvariantCulture); } - return $"{MinNumber}-{MaxNumber}"; + + return $"{MinNumber.ToString(CultureInfo.InvariantCulture)}-{MaxNumber.ToString(CultureInfo.InvariantCulture)}"; } public void ResetColorScape() diff --git a/API/Extensions/AppUserExtensions.cs b/API/Extensions/AppUserExtensions.cs index 07b348c2d..be3d2c064 100644 --- a/API/Extensions/AppUserExtensions.cs +++ b/API/Extensions/AppUserExtensions.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using API.Data.Misc; using API.Entities; using API.Helpers; @@ -44,4 +45,13 @@ public static class AppUserExtensions OrderableHelper.ReorderItems(user.SideNavStreams); } + + public static AgeRestriction GetAgeRestriction(this AppUser user) + { + return new AgeRestriction() + { + AgeRating = user.AgeRestriction, + IncludeUnknowns = user.AgeRestrictionIncludeUnknowns, + }; + } } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 4b5c73af7..bd4783f25 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -12,6 +12,7 @@ using API.SignalR.Presence; using Kavita.Common; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -24,8 +25,6 @@ public static class ApplicationServiceExtensions { services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly); - //services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -52,8 +51,11 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -68,14 +70,18 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSqLite(); services.AddSignalR(opt => opt.EnableDetailedErrors = true); @@ -83,12 +89,16 @@ public static class ApplicationServiceExtensions services.AddEasyCaching(options => { options.UseInMemory(EasyCacheProfiles.Favicon); - options.UseInMemory(EasyCacheProfiles.License); + options.UseInMemory(EasyCacheProfiles.Publisher); options.UseInMemory(EasyCacheProfiles.Library); options.UseInMemory(EasyCacheProfiles.RevokedJwt); + options.UseInMemory(EasyCacheProfiles.LocaleOptions); // KavitaPlus stuff options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries); + options.UseInMemory(EasyCacheProfiles.License); + options.UseInMemory(EasyCacheProfiles.LicenseInfo); + options.UseInMemory(EasyCacheProfiles.KavitaPlusMatchSeries); }); services.AddMemoryCache(options => @@ -113,6 +123,8 @@ public static class ApplicationServiceExtensions }); options.EnableDetailedErrors(); options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warnings => + warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); }); } } diff --git a/API/Extensions/ChapterListExtensions.cs b/API/Extensions/ChapterListExtensions.cs index 83e6880c4..5456a6e16 100644 --- a/API/Extensions/ChapterListExtensions.cs +++ b/API/Extensions/ChapterListExtensions.cs @@ -38,7 +38,7 @@ public static class ChapterListExtensions fakeChapter.UpdateFrom(info); return specialTreatment ? chapters.FirstOrDefault(c => c.Range == Parser.RemoveExtensionIfSupported(info.Filename) || c.Files.Select(f => Parser.NormalizePath(f.FilePath)).Contains(normalizedPath)) - : chapters.FirstOrDefault(c => c.Range == fakeChapter.GetNumberTitle()); // BUG: TODO: On non-english locales, for floats, the range will be 20,5 but the NumberTitle will return 20.5 + : chapters.FirstOrDefault(c => c.Range == fakeChapter.GetNumberTitle()); } /// diff --git a/API/Extensions/DoubleExtensions.cs b/API/Extensions/DoubleExtensions.cs new file mode 100644 index 000000000..3deb37ffb --- /dev/null +++ b/API/Extensions/DoubleExtensions.cs @@ -0,0 +1,26 @@ +using System; + +namespace API.Extensions; + +public static class DoubleExtensions +{ + private const float Tolerance = 0.001f; + + /// + /// Used to compare 2 floats together + /// + /// + /// + /// + public static bool Is(this double a, double? b) + { + if (!b.HasValue) return false; + return Math.Abs((float) (a - b)) < Tolerance; + } + + public static bool IsNot(this double a, double? b) + { + if (!b.HasValue) return false; + return Math.Abs((float) (a - b)) > Tolerance; + } +} diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 4e84e2fa5..9bc06bab4 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -3,7 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using API.Data.Misc; +using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; namespace API.Extensions; #nullable enable @@ -42,4 +44,28 @@ public static class EnumerableExtensions return q; } + + public static IEnumerable RestrictAgainstAgeRestriction(this IEnumerable items, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return items; + var q = items.Where(s => s.AgeRating <= restriction.AgeRating); + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.AgeRating != AgeRating.Unknown); + } + + return q; + } + + public static IEnumerable RestrictAgainstAgeRestriction(this IEnumerable items, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return items; + var q = items.Where(s => s.AgeRating <= restriction.AgeRating); + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.AgeRating != AgeRating.Unknown); + } + + return q; + } } diff --git a/API/Extensions/FlurlExtensions.cs b/API/Extensions/FlurlExtensions.cs new file mode 100644 index 000000000..62d8543b6 --- /dev/null +++ b/API/Extensions/FlurlExtensions.cs @@ -0,0 +1,35 @@ +using System; +using Flurl.Http; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; + +namespace API.Extensions; +#nullable enable + +public static class FlurlExtensions +{ + public static IFlurlRequest WithKavitaPlusHeaders(this string request, string license, string? anilistToken = null) + { + return request + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-license-key", license) + .WithHeader("x-installId", HashUtil.ServerToken()) + .WithHeader("x-anilist-token", anilistToken ?? string.Empty) + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)); + } + + public static IFlurlRequest WithBasicHeaders(this string request, string apiKey) + { + return request + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-api-key", apiKey) + .WithHeader("x-installId", HashUtil.ServerToken()) + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)); + } +} diff --git a/API/Extensions/IHasKPlusMetadataExtensions.cs b/API/Extensions/IHasKPlusMetadataExtensions.cs new file mode 100644 index 000000000..84e35adc4 --- /dev/null +++ b/API/Extensions/IHasKPlusMetadataExtensions.cs @@ -0,0 +1,21 @@ +using API.Entities.Interfaces; +using API.Entities.MetadataMatching; + +namespace API.Extensions; + +public static class IHasKPlusMetadataExtensions +{ + + public static bool HasSetKPlusMetadata(this IHasKPlusMetadata hasKPlusMetadata, MetadataSettingField field) + { + return hasKPlusMetadata.KPlusOverrides.Contains(field); + } + + public static void AddKPlusOverride(this IHasKPlusMetadata hasKPlusMetadata, MetadataSettingField field) + { + if (hasKPlusMetadata.KPlusOverrides.Contains(field)) return; + + hasKPlusMetadata.KPlusOverrides.Add(field); + } + +} diff --git a/API/Extensions/ImageExtensions.cs b/API/Extensions/ImageExtensions.cs new file mode 100644 index 000000000..5779b18ec --- /dev/null +++ b/API/Extensions/ImageExtensions.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Image = SixLabors.ImageSharp.Image; + +namespace API.Extensions; + +public static class ImageExtensions +{ + + /// + /// Structure to hold various image quality metrics + /// + private sealed class ImageQualityMetrics + { + public int Width { get; set; } + public int Height { get; set; } + public bool IsColor { get; set; } + public double Colorfulness { get; set; } + public double Contrast { get; set; } + public double Sharpness { get; set; } + public double NoiseLevel { get; set; } + } + + + /// + /// Calculate a similarity score (0-1f) based on resolution difference and MSE. + /// + /// Path to first image + /// Path to the second image + /// Similarity score between 0-1, where 1 is identical + public static float CalculateSimilarity(this string imagePath1, string imagePath2) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Load both images as Rgba32 (consistent with the rest of the code) + using var img1 = Image.Load(imagePath1); + using var img2 = Image.Load(imagePath2); + + // Calculate resolution difference factor + var res1 = img1.Width * img1.Height; + var res2 = img2.Width * img2.Height; + var resolutionDiff = Math.Abs(res1 - res2) / (float) Math.Max(res1, res2); + + // Calculate mean squared error for pixel differences + var mse = img1.GetMeanSquaredError(img2); + + // Normalize MSE (65025 = 255², which is the max possible squared difference per channel) + var normalizedMse = 1f - Math.Min(1f, mse / 65025f); + + // Final similarity score (weighted average of resolution difference and color difference) + return Math.Max(0f, 1f - (resolutionDiff * 0.5f) - (1f - normalizedMse) * 0.5f); + } + + /// + /// Smaller is better + /// + /// + /// + /// + public static float GetMeanSquaredError(this Image img1, Image img2) + { + if (img1.Width != img2.Width || img1.Height != img2.Height) + { + img2.Mutate(x => x.Resize(img1.Width, img1.Height)); + } + + double totalDiff = 0; + for (var y = 0; y < img1.Height; y++) + { + for (var x = 0; x < img1.Width; x++) + { + var pixel1 = img1[x, y]; + var pixel2 = img2[x, y]; + + var diff = Math.Pow(pixel1.R - pixel2.R, 2) + + Math.Pow(pixel1.G - pixel2.G, 2) + + Math.Pow(pixel1.B - pixel2.B, 2); + totalDiff += diff; + } + } + + return (float) (totalDiff / (img1.Width * img1.Height)); + } + + /// + /// Determines which image is "better" based on multiple quality factors + /// using only the cross-platform ImageSharp library + /// + /// Path to first image + /// Path to the second image + /// Whether to prefer color images over grayscale (default: true) + /// The path of the better image + public static string GetBetterImage(this string imagePath1, string imagePath2, bool preferColor = true) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Quick metadata check to get width/height without loading full pixel data + var info1 = Image.Identify(imagePath1); + var info2 = Image.Identify(imagePath2); + + // Calculate resolution factor + double resolutionFactor1 = info1.Width * info1.Height; + double resolutionFactor2 = info2.Width * info2.Height; + + // If one image is significantly higher resolution (3x or more), just pick it + // This avoids fully loading both images when the choice is obvious + if (resolutionFactor1 > resolutionFactor2 * 3) + return imagePath1; + if (resolutionFactor2 > resolutionFactor1 * 3) + return imagePath2; + + // Otherwise, we need to analyze the actual image data for both + + // NOTE: We HAVE to use these scope blocks and load image here otherwise memory-mapped section exception will occur + ImageQualityMetrics metrics1; + using (var img1 = Image.Load(imagePath1)) + { + metrics1 = GetImageQualityMetrics(img1); + } + + ImageQualityMetrics metrics2; + using (var img2 = Image.Load(imagePath2)) + { + metrics2 = GetImageQualityMetrics(img2); + } + + + // If one is color, and one is grayscale, then we prefer color + if (preferColor && metrics1.IsColor != metrics2.IsColor) + { + return metrics1.IsColor ? imagePath1 : imagePath2; + } + + // Calculate overall quality scores + var score1 = CalculateOverallScore(metrics1); + var score2 = CalculateOverallScore(metrics2); + + return score1 >= score2 ? imagePath1 : imagePath2; + } + + + /// + /// Calculate a weighted overall score based on metrics + /// + private static double CalculateOverallScore(ImageQualityMetrics metrics) + { + // Resolution factor (normalized to HD resolution) + var resolutionFactor = Math.Min(1.0, (metrics.Width * metrics.Height) / (double) (1920 * 1080)); + + // Color factor + var colorFactor = metrics.IsColor ? (0.5 + 0.5 * metrics.Colorfulness) : 0.3; + + // Quality factors + var contrastFactor = Math.Min(1.0, metrics.Contrast); + var sharpnessFactor = Math.Min(1.0, metrics.Sharpness); + + // Noise penalty (less noise is better) + var noisePenalty = Math.Max(0, 1.0 - metrics.NoiseLevel); + + // Weighted combination + return (resolutionFactor * 0.35) + + (colorFactor * 0.3) + + (contrastFactor * 0.15) + + (sharpnessFactor * 0.15) + + (noisePenalty * 0.05); + } + + /// + /// Gets quality metrics for an image + /// + private static ImageQualityMetrics GetImageQualityMetrics(Image image) + { + // Create a smaller version if the image is large to speed up analysis + Image workingImage; + if (image.Width > 512 || image.Height > 512) + { + workingImage = image.Clone(ctx => ctx.Resize( + new ResizeOptions { + Size = new Size(512), + Mode = ResizeMode.Max + })); + } + else + { + workingImage = image.Clone(); + } + + var metrics = new ImageQualityMetrics + { + Width = image.Width, + Height = image.Height + }; + + // Color analysis (is the image color or grayscale?) + var colorInfo = AnalyzeColorfulness(workingImage); + metrics.IsColor = colorInfo.IsColor; + metrics.Colorfulness = colorInfo.Colorfulness; + + // Contrast analysis + metrics.Contrast = CalculateContrast(workingImage); + + // Sharpness estimation + metrics.Sharpness = EstimateSharpness(workingImage); + + // Noise estimation + metrics.NoiseLevel = EstimateNoiseLevel(workingImage); + + // Clean up + workingImage.Dispose(); + + return metrics; + } + + /// + /// Analyzes colorfulness of an image + /// + private static (bool IsColor, double Colorfulness) AnalyzeColorfulness(Image image) + { + // For performance, sample a subset of pixels + var sampleSize = Math.Min(1000, image.Width * image.Height); + var stepSize = Math.Max(1, (image.Width * image.Height) / sampleSize); + + var colorCount = 0; + List<(int R, int G, int B)> samples = []; + + // Sample pixels + for (var i = 0; i < image.Width * image.Height; i += stepSize) + { + var x = i % image.Width; + var y = i / image.Width; + + var pixel = image[x, y]; + + // Check if RGB channels differ by a threshold + // High difference indicates color, low difference indicates grayscale + var rMinusG = Math.Abs(pixel.R - pixel.G); + var rMinusB = Math.Abs(pixel.R - pixel.B); + var gMinusB = Math.Abs(pixel.G - pixel.B); + + if (rMinusG > 15 || rMinusB > 15 || gMinusB > 15) + { + colorCount++; + } + + samples.Add((pixel.R, pixel.G, pixel.B)); + } + + // Calculate colorfulness metric based on Hasler and Süsstrunk's approach + // This measures the spread and intensity of colors + if (samples.Count <= 0) return (false, 0); + + // Calculate rg and yb opponent channels + var rg = samples.Select(p => p.R - p.G).ToList(); + var yb = samples.Select(p => 0.5 * (p.R + p.G) - p.B).ToList(); + + // Calculate standard deviation and mean of opponent channels + var rgStdDev = CalculateStdDev(rg); + var ybStdDev = CalculateStdDev(yb); + var rgMean = rg.Average(); + var ybMean = yb.Average(); + + // Combine into colorfulness metric + var stdRoot = Math.Sqrt(rgStdDev * rgStdDev + ybStdDev * ybStdDev); + var meanRoot = Math.Sqrt(rgMean * rgMean + ybMean * ybMean); + + var colorfulness = stdRoot + 0.3 * meanRoot; + + // Normalize to 0-1 range (typical colorfulness is 0-100) + colorfulness = Math.Min(1.0, colorfulness / 100.0); + + var isColor = (double)colorCount / samples.Count > 0.05; + + return (isColor, colorfulness); + + } + + /// + /// Calculate standard deviation of a list of values + /// + private static double CalculateStdDev(List values) + { + var mean = values.Average(); + var sumOfSquaresOfDifferences = values.Select(val => (val - mean) * (val - mean)).Sum(); + return Math.Sqrt(sumOfSquaresOfDifferences / values.Count); + } + + /// + /// Calculate standard deviation of a list of values + /// + private static double CalculateStdDev(List values) + { + var mean = values.Average(); + var sumOfSquaresOfDifferences = values.Select(val => (val - mean) * (val - mean)).Sum(); + return Math.Sqrt(sumOfSquaresOfDifferences / values.Count); + } + + /// + /// Calculates contrast of an image + /// + private static double CalculateContrast(Image image) + { + // For performance, sample a subset of pixels + var sampleSize = Math.Min(1000, image.Width * image.Height); + var stepSize = Math.Max(1, (image.Width * image.Height) / sampleSize); + + List luminanceValues = new(); + + // Sample pixels and calculate luminance + for (var i = 0; i < image.Width * image.Height; i += stepSize) + { + var x = i % image.Width; + var y = i / image.Width; + + var pixel = image[x, y]; + + // Calculate luminance + var luminance = (int)(0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B); + luminanceValues.Add(luminance); + } + + if (luminanceValues.Count < 2) + return 0; + + // Use RMS contrast (root-mean-square of pixel intensity) + var mean = luminanceValues.Average(); + var sumOfSquaresOfDifferences = luminanceValues.Sum(l => Math.Pow(l - mean, 2)); + var rmsContrast = Math.Sqrt(sumOfSquaresOfDifferences / luminanceValues.Count) / mean; + + // Normalize to 0-1 range + return Math.Min(1.0, rmsContrast); + } + + /// + /// Estimates sharpness using simple Laplacian-based method + /// + private static double EstimateSharpness(Image image) + { + // For simplicity, convert to grayscale + var grayImage = new int[image.Width, image.Height]; + + // Convert to grayscale + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < image.Width; x++) + { + var pixel = image[x, y]; + grayImage[x, y] = (int)(0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B); + } + } + + // Apply Laplacian filter (3x3) + // The Laplacian measures local variations - higher values indicate edges/details + double laplacianSum = 0; + var validPixels = 0; + + // Laplacian kernel: [0, 1, 0, 1, -4, 1, 0, 1, 0] + for (var y = 1; y < image.Height - 1; y++) + { + for (var x = 1; x < image.Width - 1; x++) + { + var laplacian = + grayImage[x, y - 1] + + grayImage[x - 1, y] - 4 * grayImage[x, y] + grayImage[x + 1, y] + + grayImage[x, y + 1]; + + laplacianSum += Math.Abs(laplacian); + validPixels++; + } + } + + if (validPixels == 0) + return 0; + + // Calculate variance of Laplacian + var laplacianVariance = laplacianSum / validPixels; + + // Normalize to 0-1 range (typical values range from 0-1000) + return Math.Min(1.0, laplacianVariance / 1000.0); + } + + /// + /// Estimates noise level using simple block-based variance method + /// + private static double EstimateNoiseLevel(Image image) + { + // Block size for noise estimation + const int blockSize = 8; + List blockVariances = new(); + + // Calculate variance in small blocks throughout the image + for (var y = 0; y < image.Height - blockSize; y += blockSize) + { + for (var x = 0; x < image.Width - blockSize; x += blockSize) + { + List blockValues = new(); + + // Sample block + for (var by = 0; by < blockSize; by++) + { + for (var bx = 0; bx < blockSize; bx++) + { + var pixel = image[x + bx, y + by]; + var value = (int)(0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B); + blockValues.Add(value); + } + } + + // Calculate variance of this block + var blockMean = blockValues.Average(); + var blockVariance = blockValues.Sum(v => Math.Pow(v - blockMean, 2)) / blockValues.Count; + blockVariances.Add(blockVariance); + } + } + + if (blockVariances.Count == 0) + return 0; + + // Sort block variances and take lowest 10% (likely uniform areas where noise is most visible) + blockVariances.Sort(); + var smoothBlocksCount = Math.Max(1, blockVariances.Count / 10); + var averageNoiseVariance = blockVariances.Take(smoothBlocksCount).Average(); + + // Normalize to 0-1 range (typical noise variances are 0-100) + return Math.Min(1.0, averageNoiseVariance / 100.0); + } +} diff --git a/API/Extensions/PlusMediaFormatExtensions.cs b/API/Extensions/PlusMediaFormatExtensions.cs new file mode 100644 index 000000000..a88b9c2f9 --- /dev/null +++ b/API/Extensions/PlusMediaFormatExtensions.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using API.DTOs.Scrobbling; +using API.Entities.Enums; + +namespace API.Extensions; + +public static class PlusMediaFormatExtensions +{ + public static PlusMediaFormat ConvertToPlusMediaFormat(this LibraryType libraryType, MangaFormat? seriesFormat = null) + { + + return libraryType switch + { + LibraryType.Manga => seriesFormat is MangaFormat.Epub ? PlusMediaFormat.LightNovel : PlusMediaFormat.Manga, + LibraryType.Comic => PlusMediaFormat.Comic, + LibraryType.LightNovel => PlusMediaFormat.LightNovel, + LibraryType.Book => PlusMediaFormat.LightNovel, + LibraryType.Image => PlusMediaFormat.Manga, + LibraryType.ComicVine => PlusMediaFormat.Comic, + _ => throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null) + }; + } + + public static IEnumerable ConvertToLibraryTypes(this PlusMediaFormat plusMediaFormat) + { + return plusMediaFormat switch + { + PlusMediaFormat.Manga => [LibraryType.Manga, LibraryType.Image], + PlusMediaFormat.Comic => [LibraryType.Comic, LibraryType.ComicVine], + PlusMediaFormat.LightNovel => [LibraryType.LightNovel, LibraryType.Book, LibraryType.Manga], + PlusMediaFormat.Book => [LibraryType.LightNovel, LibraryType.Book], + _ => throw new ArgumentOutOfRangeException(nameof(plusMediaFormat), plusMediaFormat, null) + }; + } + + public static IList GetMangaFormats(this PlusMediaFormat? mediaFormat) + { + return mediaFormat.HasValue ? mediaFormat.Value.GetMangaFormats() : [MangaFormat.Archive]; + } + + public static IList GetMangaFormats(this PlusMediaFormat mediaFormat) + { + return mediaFormat switch + { + PlusMediaFormat.Manga => [MangaFormat.Archive, MangaFormat.Image], + PlusMediaFormat.Comic => [MangaFormat.Archive], + PlusMediaFormat.LightNovel => [MangaFormat.Epub, MangaFormat.Pdf], + PlusMediaFormat.Book => [MangaFormat.Epub, MangaFormat.Pdf], + _ => [MangaFormat.Archive] + }; + } + + +} diff --git a/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs b/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs new file mode 100644 index 000000000..c36164d9d --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; +using API.Entities.Person; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions.Filtering; + +public static class PersonFilter +{ + public static IQueryable HasPersonName(this IQueryable queryable, bool condition, + FilterComparison comparison, string queryString) + { + if (string.IsNullOrEmpty(queryString) || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => p.Name.Equals(queryString)), + FilterComparison.BeginsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"{queryString}%")), + FilterComparison.EndsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}")), + FilterComparison.Matches => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}%")), + FilterComparison.NotEqual => queryable.Where(p => p.Name != queryString), + FilterComparison.NotContains or FilterComparison.GreaterThan or FilterComparison.GreaterThanEqual + or FilterComparison.LessThan or FilterComparison.LessThanEqual or FilterComparison.Contains + or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast + or FilterComparison.IsNotInLast or FilterComparison.MustContains + or FilterComparison.IsEmpty => + throw new KavitaException($"{comparison} not applicable for Person.Name"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "Filter Comparison is not supported") + }; + } + public static IQueryable HasPersonRole(this IQueryable queryable, bool condition, + FilterComparison comparison, IList roles) + { + if (roles == null || roles.Count == 0 || !condition) return queryable; + + return comparison switch + { + FilterComparison.Contains or FilterComparison.MustContains => queryable.Where(p => + p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || + p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))), + FilterComparison.NotContains => queryable.Where(p => + !p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) && + !p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))), + FilterComparison.Equal or FilterComparison.NotEqual or FilterComparison.BeginsWith + or FilterComparison.EndsWith or FilterComparison.Matches or FilterComparison.GreaterThan + or FilterComparison.GreaterThanEqual or FilterComparison.LessThan or FilterComparison.LessThanEqual + or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast + or FilterComparison.IsNotInLast + or FilterComparison.IsEmpty => + throw new KavitaException($"{comparison} not applicable for Person.Role"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "Filter Comparison is not supported") + }; + } + + public static IQueryable HasPersonSeriesCount(this IQueryable queryable, bool condition, + FilterComparison comparison, int count) + { + if (!condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() == count), + FilterComparison.GreaterThan => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() > count), + FilterComparison.GreaterThanEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() >= count), + FilterComparison.LessThan => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() < count), + FilterComparison.LessThanEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() <= count), + FilterComparison.NotEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() != count), + FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches + or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore + or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast + or FilterComparison.MustContains + or FilterComparison.IsEmpty => throw new KavitaException( + $"{comparison} not applicable for Person.SeriesCount"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported") + }; + } + + public static IQueryable HasPersonChapterCount(this IQueryable queryable, bool condition, + FilterComparison comparison, int count) + { + if (!condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() == count), + FilterComparison.GreaterThan => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() > count), + FilterComparison.GreaterThanEqual => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() >= count), + FilterComparison.LessThan => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() < count), + FilterComparison.LessThanEqual => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() <= count), + FilterComparison.NotEqual => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() != count), + FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches + or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore + or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast + or FilterComparison.MustContains + or FilterComparison.IsEmpty => throw new KavitaException( + $"{comparison} not applicable for Person.ChapterCount"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported") + }; + } +} diff --git a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs index f6606026b..d7acf9381 100644 --- a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs @@ -1,10 +1,11 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using API.Data.Misc; using API.Data.Repositories; using API.Entities; using API.Entities.Metadata; -using AutoMapper.QueryableExtensions; +using API.Entities.Person; using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions.Filtering; @@ -49,23 +50,26 @@ public static class SearchQueryableExtensions // Get people from SeriesMetadata var peopleFromSeriesMetadata = queryable .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.People) - .Where(p => p.Person.Name != null && EF.Functions.Like(p.Person.Name, $"%{searchQuery}%")) - .Select(p => p.Person); + .SelectMany(sm => sm.People.Select(sp => sp.Person)) + .Where(p => + EF.Functions.Like(p.Name, $"%{searchQuery}%") || + p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%")) + ); - // Get people from ChapterPeople by navigating through Volume -> Series var peopleFromChapterPeople = queryable .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.Series.Volumes) .SelectMany(v => v.Chapters) - .SelectMany(ch => ch.People) - .Where(cp => cp.Person.Name != null && EF.Functions.Like(cp.Person.Name, $"%{searchQuery}%")) - .Select(cp => cp.Person); + .SelectMany(ch => ch.People.Select(cp => cp.Person)) + .Where(p => + EF.Functions.Like(p.Name, $"%{searchQuery}%") || + p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%")) + ); // Combine both queries and ensure distinct results return peopleFromSeriesMetadata .Union(peopleFromChapterPeople) - .Distinct() + .Select(p => p) .OrderBy(p => p.NormalizedName); } diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index b6f1082af..ad51a4a62 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -14,6 +14,7 @@ namespace API.Extensions.QueryExtensions.Filtering; public static class SeriesFilter { private const float FloatingPointTolerance = 0.001f; + public static IQueryable HasLanguage(this IQueryable queryable, bool condition, FilterComparison comparison, IList languages) { @@ -93,11 +94,9 @@ public static class SeriesFilter { if (rating < 0 || !condition || userId <= 0) return queryable; - // Users see rating as %, so they are likely to pass 10%. We need to turn that into the underlying float encoding - if (rating.IsNot(0f)) - { - rating /= 100f; - } + // AppUserRating stores a 5-digit number. + rating = Math.Clamp(rating, 0f, 5f); + switch (comparison) { @@ -169,6 +168,7 @@ public static class SeriesFilter throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } } + public static IQueryable HasAverageReadTime(this IQueryable queryable, bool condition, FilterComparison comparison, int avgReadTime) { @@ -177,17 +177,17 @@ public static class SeriesFilter switch (comparison) { case FilterComparison.NotEqual: - return queryable.Where(s => s.AvgHoursToRead != avgReadTime); + return queryable.WhereNotEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.Equal: - return queryable.Where(s => s.AvgHoursToRead == avgReadTime); + return queryable.WhereEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.GreaterThan: - return queryable.Where(s => s.AvgHoursToRead > avgReadTime); + return queryable.WhereGreaterThan(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.GreaterThanEqual: - return queryable.Where(s => s.AvgHoursToRead >= avgReadTime); + return queryable.WhereGreaterThanOrEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.LessThan: - return queryable.Where(s => s.AvgHoursToRead < avgReadTime); + return queryable.WhereLessThan(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.LessThanEqual: - return queryable.Where(s => s.AvgHoursToRead <= avgReadTime); + return queryable.WhereLessThanOrEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.Contains: case FilterComparison.Matches: case FilterComparison.NotContains: @@ -252,37 +252,35 @@ public static class SeriesFilter if (!condition) return queryable; var subQuery = queryable - .Include(s => s.Progress) - .Where(s => s.Progress != null) .Select(s => new { - Series = s, - Percentage = ((float) s.Progress + SeriesId = s.Id, + SeriesName = s.Name, + Percentage = s.Progress .Where(p => p != null && p.AppUserId == userId) - .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100) + .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0f) * 100f }) - .AsSplitQuery() - .AsEnumerable(); + .AsSplitQuery(); switch (comparison) { case FilterComparison.Equal: - subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) < FloatingPointTolerance); + subQuery = subQuery.WhereEqual(s => s.Percentage, readProgress); break; case FilterComparison.GreaterThan: - subQuery = subQuery.Where(s => s.Percentage > readProgress); + subQuery = subQuery.WhereGreaterThan(s => s.Percentage, readProgress); break; case FilterComparison.GreaterThanEqual: - subQuery = subQuery.Where(s => s.Percentage >= readProgress); + subQuery = subQuery.WhereGreaterThanOrEqual(s => s.Percentage, readProgress); break; case FilterComparison.LessThan: - subQuery = subQuery.Where(s => s.Percentage < readProgress); + subQuery = subQuery.WhereLessThan(s => s.Percentage, readProgress); break; case FilterComparison.LessThanEqual: - subQuery = subQuery.Where(s => s.Percentage <= readProgress); + subQuery = subQuery.WhereLessThanOrEqual(s => s.Percentage, readProgress); break; case FilterComparison.NotEqual: - subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) > FloatingPointTolerance); + subQuery = subQuery.WhereNotEqual(s => s.Percentage, readProgress); break; case FilterComparison.IsEmpty: case FilterComparison.Matches: @@ -300,7 +298,7 @@ public static class SeriesFilter throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } - var ids = subQuery.Select(s => s.Series.Id).ToList(); + var ids = subQuery.Select(s => s.SeriesId); return queryable.Where(s => ids.Contains(s.Id)); } @@ -309,37 +307,37 @@ public static class SeriesFilter { if (!condition) return queryable; - var subQuery = queryable .Where(s => s.ExternalSeriesMetadata != null) .Include(s => s.ExternalSeriesMetadata) .Select(s => new { - Series = s, + SeriesId = s.Id, + SeriesName = s.Name, AverageRating = s.ExternalSeriesMetadata.AverageExternalRating }) .AsSplitQuery() - .AsEnumerable(); + .AsQueryable(); switch (comparison) { case FilterComparison.Equal: - subQuery = subQuery.Where(s => Math.Abs(s.AverageRating - rating) < FloatingPointTolerance); + subQuery = subQuery.WhereEqual(s => s.AverageRating, rating); break; case FilterComparison.GreaterThan: - subQuery = subQuery.Where(s => s.AverageRating > rating); + subQuery = subQuery.WhereGreaterThan(s => s.AverageRating, rating); break; case FilterComparison.GreaterThanEqual: - subQuery = subQuery.Where(s => s.AverageRating >= rating); + subQuery = subQuery.WhereGreaterThanOrEqual(s => s.AverageRating, rating); break; case FilterComparison.LessThan: - subQuery = subQuery.Where(s => s.AverageRating < rating); + subQuery = subQuery.WhereLessThan(s => s.AverageRating, rating); break; case FilterComparison.LessThanEqual: - subQuery = subQuery.Where(s => s.AverageRating <= rating); + subQuery = subQuery.WhereLessThanOrEqual(s => s.AverageRating, rating); break; case FilterComparison.NotEqual: - subQuery = subQuery.Where(s => Math.Abs(s.AverageRating - rating) > FloatingPointTolerance); + subQuery = subQuery.WhereNotEqual(s => s.AverageRating, rating); break; case FilterComparison.Matches: case FilterComparison.Contains: @@ -357,7 +355,74 @@ public static class SeriesFilter throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } - var ids = subQuery.Select(s => s.Series.Id).ToList(); + var ids = subQuery.Select(s => s.SeriesId); + return queryable.Where(s => ids.Contains(s.Id)); + } + + /// + /// HasReadingDate but used to filter where last reading point was TODAY() - timeDeltaDays. This allows the user + /// to build smart filters "Haven't read in a month" + /// + public static IQueryable HasReadLast(this IQueryable queryable, bool condition, + FilterComparison comparison, int timeDeltaDays, int userId) + { + if (!condition || timeDeltaDays == 0) return queryable; + + var subQuery = queryable + .Include(s => s.Progress) + .Where(s => s.Progress.Any()) + .Select(s => new + { + SeriesId = s.Id, + SeriesName = s.Name, + MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId) + .Select(p => (DateTime?) p.LastModified) + .DefaultIfEmpty() + .Max() + }) + .Where(s => s.MaxDate != null) + .AsSplitQuery() + .AsEnumerable(); + + var date = DateTime.Now.AddDays(-timeDeltaDays); + + switch (comparison) + { + case FilterComparison.Equal: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate.Equals(date)); + break; + case FilterComparison.IsAfter: + case FilterComparison.GreaterThan: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate > date); + break; + case FilterComparison.GreaterThanEqual: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate >= date); + break; + case FilterComparison.IsBefore: + case FilterComparison.LessThan: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate < date); + break; + case FilterComparison.LessThanEqual: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate <= date); + break; + case FilterComparison.NotEqual: + subQuery = subQuery.Where(s => s.MaxDate != null && !s.MaxDate.Equals(date)); + break; + case FilterComparison.Matches: + case FilterComparison.Contains: + case FilterComparison.NotContains: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + case FilterComparison.MustContains: + case FilterComparison.IsEmpty: + throw new KavitaException($"{comparison} not applicable for Series.ReadProgress"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + + var ids = subQuery.Select(s => s.SeriesId); return queryable.Where(s => ids.Contains(s.Id)); } @@ -368,10 +433,11 @@ public static class SeriesFilter var subQuery = queryable .Include(s => s.Progress) - .Where(s => s.Progress != null) + .Where(s => s.Progress.Any()) .Select(s => new { - Series = s, + SeriesId = s.Id, + SeriesName = s.Name, MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId) .Select(p => (DateTime?) p.LastModified) .DefaultIfEmpty() @@ -417,14 +483,14 @@ public static class SeriesFilter throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } - var ids = subQuery.Select(s => s.Series.Id).ToList(); + var ids = subQuery.Select(s => s.SeriesId); return queryable.Where(s => ids.Contains(s.Id)); } public static IQueryable HasTags(this IQueryable queryable, bool condition, FilterComparison comparison, IList tags) { - if (!condition || tags.Count == 0) return queryable; + if (!condition || (comparison != FilterComparison.IsEmpty && tags.Count == 0)) return queryable; switch (comparison) { @@ -444,7 +510,7 @@ public static class SeriesFilter return queries.Aggregate((q1, q2) => q1.Intersect(q2)); case FilterComparison.IsEmpty: - return queryable.Where(s => s.Metadata.Tags == null || s.Metadata.Tags.Count == 0); + return queryable.Where(s => s.Metadata.Tags.Count == 0); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -471,21 +537,21 @@ public static class SeriesFilter { case FilterComparison.Equal: case FilterComparison.Contains: - return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.PersonId))); + return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.PersonId) && p.Role == role)); case FilterComparison.NotEqual: case FilterComparison.NotContains: - return queryable.Where(s => s.Metadata.People.All(t => !people.Contains(t.PersonId))); + return queryable.Where(s => s.Metadata.People.All(p => !people.Contains(p.PersonId) || p.Role != role)); case FilterComparison.MustContains: - // Deconstruct and do a Union of a bunch of where statements since this doesn't translate var queries = new List>() { queryable }; - queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.PersonId == gId)))); + queries.AddRange(people.Select(personId => + queryable.Where(s => s.Metadata.People.Any(p => p.PersonId == personId && p.Role == role)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); case FilterComparison.IsEmpty: - // Check if there are no people with specific roles (e.g., Writer, Penciller, etc.) + // Ensure no person with the given role exists return queryable.Where(s => s.Metadata.People.All(p => p.Role != role)); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: @@ -547,7 +613,7 @@ public static class SeriesFilter public static IQueryable HasGenre(this IQueryable queryable, bool condition, FilterComparison comparison, IList genres) { - if (!condition || genres.Count == 0) return queryable; + if (!condition || (comparison != FilterComparison.IsEmpty && genres.Count == 0)) return queryable; switch (comparison) { @@ -620,7 +686,7 @@ public static class SeriesFilter public static IQueryable HasCollectionTags(this IQueryable queryable, bool condition, FilterComparison comparison, IList collectionTags, IList collectionSeries) { - if (!condition || collectionTags.Count == 0) return queryable; + if (!condition || (comparison != FilterComparison.IsEmpty && collectionTags.Count == 0)) return queryable; switch (comparison) @@ -641,7 +707,7 @@ public static class SeriesFilter return queries.Aggregate((q1, q2) => q1.Intersect(q2)); case FilterComparison.IsEmpty: - return queryable.Where(s => collectionSeries.All(c => c != s.Id)); + return queryable.Where(s => s.Collections.Count == 0); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 983f6798e..bfc585455 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -1,6 +1,7 @@ using System.Linq; using API.Data.Repositories; using API.Entities; +using API.Entities.Person; using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions; @@ -72,6 +73,18 @@ public static class IncludesExtensions .Include(c => c.Tags); } + if (includes.HasFlag(ChapterIncludes.ExternalReviews)) + { + queryable = queryable + .Include(c => c.ExternalReviews); + } + + if (includes.HasFlag(ChapterIncludes.ExternalRatings)) + { + queryable = queryable + .Include(c => c.ExternalRatings); + } + return queryable.AsSplitQuery(); } @@ -253,6 +266,11 @@ public static class IncludesExtensions .ThenInclude(c => c.Items); } + if (includeFlags.HasFlag(AppUserIncludes.ChapterRatings)) + { + query = query.Include(u => u.ChapterRatings); + } + return query.AsSplitQuery(); } @@ -303,4 +321,25 @@ public static class IncludesExtensions return query.AsSplitQuery(); } + + public static IQueryable Includes(this IQueryable queryable, PersonIncludes includeFlags) + { + + if (includeFlags.HasFlag(PersonIncludes.Aliases)) + { + queryable = queryable.Include(p => p.Aliases); + } + + if (includeFlags.HasFlag(PersonIncludes.ChapterPeople)) + { + queryable = queryable.Include(p => p.ChapterPeople); + } + + if (includeFlags.HasFlag(PersonIncludes.SeriesPeople)) + { + queryable = queryable.Include(p => p.SeriesMetadataPeople); + } + + return queryable; + } } diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index 201c8dd28..ef2af721f 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -5,9 +5,13 @@ using System.Linq.Expressions; using System.Threading.Tasks; using API.Data.Misc; using API.Data.Repositories; +using API.DTOs; using API.DTOs.Filtering; +using API.DTOs.KavitaPlus.Manage; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Entities.Enums; +using API.Entities.Person; using API.Entities.Scrobble; using Microsoft.EntityFrameworkCore; @@ -16,6 +20,8 @@ namespace API.Extensions.QueryExtensions; public static class QueryableExtensions { + private const float DefaultTolerance = 0.001f; + public static Task GetUserAgeRestriction(this DbSet queryable, int userId) { if (userId < 1) @@ -79,7 +85,6 @@ public static class QueryableExtensions .Include(l => l.AppUsers) .Where(lib => lib.AppUsers.Any(user => user.Id == userId)) .IsRestricted(queryContext) - .AsNoTracking() .AsSplitQuery() .Select(lib => lib.Id); } @@ -112,18 +117,98 @@ public static class QueryableExtensions return condition ? queryable.Where(predicate) : queryable; } - public static IQueryable WhereLike(this IQueryable queryable, bool condition, Expression> propertySelector, string searchQuery) - where T : class + + public static IQueryable WhereGreaterThan(this IQueryable source, + Expression> selector, + float value) { - if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable; + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; - var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) }); - var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null); - var searchExpression = Expression.Constant($"%{searchQuery}%"); - var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression); - var lambda = Expression.Lambda>(likeExpression, propertySelector.Parameters[0]); + var greaterThanExpression = Expression.GreaterThan(propertyAccess, Expression.Constant(value)); + var lambda = Expression.Lambda>(greaterThanExpression, parameter); - return queryable.Where(lambda); + return source.Where(lambda); + } + + public static IQueryable WhereGreaterThanOrEqual(this IQueryable source, + Expression> selector, + float value) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var greaterThanExpression = Expression.GreaterThanOrEqual(propertyAccess, Expression.Constant(value)); + var lambda = Expression.Lambda>(greaterThanExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereLessThan(this IQueryable source, + Expression> selector, + float value) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var lessThanExpression = Expression.LessThan(propertyAccess, Expression.Constant(value)); + var lambda = Expression.Lambda>(lessThanExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereLessThanOrEqual(this IQueryable source, + Expression> selector, + float value) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var lessThanOrEqualExpression = Expression.LessThanOrEqual(propertyAccess, Expression.Constant(value)); + var lambda = Expression.Lambda>(lessThanOrEqualExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereEqual(this IQueryable source, + Expression> selector, + float value, + float tolerance = DefaultTolerance) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + // Absolute difference comparison: Math.Abs(propertyAccess - value) < tolerance + var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); + var absoluteDifference = Expression.Condition( + Expression.LessThan(difference, Expression.Constant(0f)), + Expression.Negate(difference), + difference); + + var toleranceExpression = Expression.LessThan(absoluteDifference, Expression.Constant(tolerance)); + var lambda = Expression.Lambda>(toleranceExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereNotEqual(this IQueryable source, + Expression> selector, + float value, + float tolerance = DefaultTolerance) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); + var absoluteDifference = Expression.Condition( + Expression.LessThan(difference, Expression.Constant(0f)), + Expression.Negate(difference), + difference); + + var toleranceExpression = Expression.GreaterThan(absoluteDifference, Expression.Constant(tolerance)); + var lambda = Expression.Lambda>(toleranceExpression, parameter); + + return source.Where(lambda); } /// @@ -173,6 +258,7 @@ public static class QueryableExtensions ScrobbleEventSortField.Type => query.OrderByDescending(s => s.ScrobbleEventType), ScrobbleEventSortField.Series => query.OrderByDescending(s => s.Series.NormalizedName), ScrobbleEventSortField.IsProcessed => query.OrderByDescending(s => s.IsProcessed), + ScrobbleEventSortField.ScrobbleEventFilter => query.OrderByDescending(s => s.ScrobbleEventType), _ => query }; } @@ -185,10 +271,32 @@ public static class QueryableExtensions ScrobbleEventSortField.Type => query.OrderBy(s => s.ScrobbleEventType), ScrobbleEventSortField.Series => query.OrderBy(s => s.Series.NormalizedName), ScrobbleEventSortField.IsProcessed => query.OrderBy(s => s.IsProcessed), + ScrobbleEventSortField.ScrobbleEventFilter => query.OrderBy(s => s.ScrobbleEventType), _ => query }; } + public static IQueryable SortBy(this IQueryable query, PersonSortOptions? sort) + { + if (sort == null) + { + return query.OrderBy(p => p.Name); + } + + return sort.SortField switch + { + PersonSortField.Name when sort.IsAscending => query.OrderBy(p => p.Name), + PersonSortField.Name => query.OrderByDescending(p => p.Name), + PersonSortField.SeriesCount when sort.IsAscending => query.OrderBy(p => p.SeriesMetadataPeople.Count), + PersonSortField.SeriesCount => query.OrderByDescending(p => p.SeriesMetadataPeople.Count), + PersonSortField.ChapterCount when sort.IsAscending => query.OrderBy(p => p.ChapterPeople.Count), + PersonSortField.ChapterCount => query.OrderByDescending(p => p.ChapterPeople.Count), + _ => query.OrderBy(p => p.Name) + }; + + + } + /// /// Performs either OrderBy or OrderByDescending on the given query based on the value of SortOptions.IsAscending. /// @@ -200,4 +308,21 @@ public static class QueryableExtensions { return sortOptions.IsAscending ? query.OrderBy(keySelector) : query.OrderByDescending(keySelector); } + + public static IQueryable FilterMatchState(this IQueryable query, MatchStateOption stateOption) + { + return stateOption switch + { + MatchStateOption.All => query, + MatchStateOption.Matched => query + .Include(s => s.ExternalSeriesMetadata) + .Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted), + MatchStateOption.NotMatched => query. + Include(s => s.ExternalSeriesMetadata) + .Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted && !s.DontMatch), + MatchStateOption.Error => query.Where(s => s.IsBlacklisted && !s.DontMatch), + MatchStateOption.DontMatch => query.Where(s => s.DontMatch), + _ => query + }; + } } diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index b8def7377..e0738bdf3 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -3,6 +3,8 @@ using System.Linq; using API.Data.Misc; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; +using API.Entities.Person; namespace API.Extensions.QueryExtensions; #nullable enable @@ -25,6 +27,20 @@ public static class RestrictByAgeExtensions return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(s => s.SeriesMetadata.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.SeriesMetadata.AgeRating != AgeRating.Unknown); + } + + return q; + } + + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; @@ -38,21 +54,20 @@ public static class RestrictByAgeExtensions return q; } - [Obsolete] - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(cp => cp.Chapter.Volume.Series.Metadata.AgeRating <= restriction.AgeRating); - if (restriction.IncludeUnknowns) + if (!restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return q.Where(cp => cp.Chapter.Volume.Series.Metadata.AgeRating != AgeRating.Unknown); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; @@ -67,18 +82,27 @@ public static class RestrictByAgeExtensions sm.Metadata.AgeRating <= restriction.AgeRating && sm.Metadata.AgeRating > AgeRating.Unknown)); } + /// + /// Returns all Genres where any of the linked Series/Chapters are less than or equal to restriction age rating + /// + /// + /// + /// public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating && sm.AgeRating != AgeRating.Unknown) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating && cp.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) @@ -87,12 +111,15 @@ public static class RestrictByAgeExtensions if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating && sm.AgeRating != AgeRating.Unknown) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating && cp.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) @@ -101,12 +128,15 @@ public static class RestrictByAgeExtensions if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadataPeople.All(sm => - sm.SeriesMetadata.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadataPeople.Any(sm => sm.SeriesMetadata.AgeRating <= restriction.AgeRating) || + c.ChapterPeople.Any(cp => cp.Chapter.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadataPeople.All(sm => - sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadataPeople.Any(sm => sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating != AgeRating.Unknown) || + c.ChapterPeople.Any(cp => cp.Chapter.AgeRating <= restriction.AgeRating && cp.Chapter.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) diff --git a/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs b/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs new file mode 100644 index 000000000..9ec1b8621 --- /dev/null +++ b/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs @@ -0,0 +1,31 @@ +using System.Linq; +using API.Entities; +using API.Entities.Person; + +namespace API.Extensions.QueryExtensions; + +public static class RestrictByLibraryExtensions +{ + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(p => + p.ChapterPeople.Any(cp => userLibs.Contains(cp.Chapter.Volume.Series.LibraryId)) || + p.SeriesMetadataPeople.Any(sm => userLibs.Contains(sm.SeriesMetadata.Series.LibraryId))); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(cp => userLibs.Contains(cp.Volume.Series.LibraryId)); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(sm => userLibs.Contains(sm.SeriesMetadata.Series.LibraryId)); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(cp => userLibs.Contains(cp.Chapter.Volume.Series.LibraryId)); + } +} diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs index 802c4bca4..28419921a 100644 --- a/API/Extensions/StringExtensions.cs +++ b/API/Extensions/StringExtensions.cs @@ -1,4 +1,5 @@ -using System.Globalization; +using System; +using System.Globalization; using System.Text.RegularExpressions; namespace API.Extensions; @@ -10,6 +11,23 @@ public static class StringExtensions RegexOptions.ExplicitCapture | RegexOptions.Compiled, Services.Tasks.Scanner.Parser.Parser.RegexTimeout); + public static string Sanitize(this string input) + { + if (string.IsNullOrEmpty(input)) + return string.Empty; + + // Remove all newline and control characters + var sanitized = input + .Replace(Environment.NewLine, string.Empty) + .Replace("\n", string.Empty) + .Replace("\r", string.Empty); + + // Optionally remove other potentially unwanted characters + sanitized = Regex.Replace(sanitized, @"[^\u0020-\u007E]", string.Empty); // Removes non-printable ASCII + + return sanitized.Trim(); // Trim any leading/trailing whitespace + } + public static string SentenceCase(this string value) { return SentenceCaseRegex.Replace(value.ToLower(), s => s.Value.ToUpper()); diff --git a/API/Extensions/VersionExtensions.cs b/API/Extensions/VersionExtensions.cs new file mode 100644 index 000000000..1877b48b1 --- /dev/null +++ b/API/Extensions/VersionExtensions.cs @@ -0,0 +1,17 @@ +using System; + +namespace API.Extensions; + +public static class VersionExtensions +{ + public static bool CompareWithoutRevision(this Version v1, Version v2) + { + if (v1.Major != v2.Major) + return v1.Major == v2.Major; + if (v1.Minor != v2.Minor) + return v1.Minor == v2.Minor; + if (v1.Build != v2.Build) + return v1.Build == v2.Build; + return true; + } +} diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 89cd93d12..bb7511c64 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -8,10 +8,14 @@ using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Dashboard; using API.DTOs.Device; +using API.DTOs.Email; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; +using API.DTOs.KavitaPlus.Manage; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.MediaErrors; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.Progress; using API.DTOs.Reader; using API.DTOs.ReadingLists; @@ -26,12 +30,16 @@ using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Entities.Scrobble; using API.Extensions.QueryExtensions.Filtering; using API.Helpers.Converters; using API.Services; using AutoMapper; using CollectionTag = API.Entities.CollectionTag; +using EmailHistory = API.Entities.EmailHistory; +using ExternalSeriesMetadata = API.Entities.Metadata.ExternalSeriesMetadata; using MediaError = API.Entities.MediaError; using PublicationStatus = API.Entities.Enums.PublicationStatus; using SiteTheme = API.Entities.SiteTheme; @@ -59,8 +67,10 @@ public class AutoMapperProfiles : Profile CreateMap(); CreateMap(); CreateMap() - .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)); - CreateMap(); + .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)) + .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); + CreateMap() + .ForMember(dest => dest.Aliases, opt => opt.MapFrom(src => src.Aliases.Select(s => s.Alias))); CreateMap(); CreateMap(); CreateMap(); @@ -89,6 +99,16 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.Username, opt => opt.MapFrom(src => src.AppUser.UserName)); + CreateMap() + .ForMember(dest => dest.LibraryId, + opt => + opt.MapFrom(src => src.Series.LibraryId)) + .ForMember(dest => dest.Body, + opt => + opt.MapFrom(src => src.Review)) + .ForMember(dest => dest.Username, + opt => + opt.MapFrom(src => src.AppUser.UserName)); CreateMap() .ForMember(dest => dest.PageNum, @@ -115,8 +135,8 @@ public class AutoMapperProfiles : Profile // Map Characters .ForMember(dest => dest.Characters, opt => opt.MapFrom(src => src.People .Where(cp => cp.Role == PersonRole.Character) - .Select(cp => cp.Person) - .OrderBy(p => p.NormalizedName))) + .OrderBy(cp => cp.OrderWeight) + .Select(cp => cp.Person))) // Map Pencillers .ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People .Where(cp => cp.Role == PersonRole.Penciller) @@ -255,18 +275,19 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.Theme, opt => - opt.MapFrom(src => src.Theme)) + opt.MapFrom(src => src.Theme)); + + CreateMap() .ForMember(dest => dest.BookReaderThemeName, opt => - opt.MapFrom(src => src.BookThemeName)) - .ForMember(dest => dest.BookReaderLayoutMode, - opt => - opt.MapFrom(src => src.BookReaderLayoutMode)); + opt.MapFrom(src => src.BookThemeName)); CreateMap(); - CreateMap(); + CreateMap() + .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)) + .ForMember(dest => dest.OwnerUserName, opt => opt.MapFrom(src => src.AppUser.UserName)); CreateMap(); CreateMap(); CreateMap(); @@ -329,17 +350,43 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.BodyJustText, opt => - opt.MapFrom(src => ReviewService.GetCharacters(src.Body))); + opt.MapFrom(src => ReviewHelper.GetCharacters(src.Body))); CreateMap(); + CreateMap() + .ForMember(dest => dest.Series, + opt => + opt.MapFrom(src => src)) + .ForMember(dest => dest.IsMatched, + opt => + opt.MapFrom(src => src.ExternalSeriesMetadata != null && src.ExternalSeriesMetadata.AniListId != 0 + && src.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue)) + .ForMember(dest => dest.ValidUntilUtc, + opt => opt.MapFrom(src => + src.ExternalSeriesMetadata != null + ? src.ExternalSeriesMetadata.ValidUntilUtc + : DateTime.MinValue)); CreateMap(); + CreateMap() + .ForMember(dest => dest.ToUserName, opt => opt.MapFrom(src => src.AppUser.UserName)); CreateMap() .ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.Volume.SeriesId)) .ForMember(dest => dest.VolumeTitle, opt => opt.MapFrom(src => src.Volume.Name)) .ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId)) .ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type)); + + CreateMap(); + + CreateMap() + .ForMember(dest => dest.Blacklist, opt => opt.MapFrom(src => src.Blacklist ?? new List())) + .ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List())) + .ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List())) + .ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary())); + + + } } diff --git a/API/Helpers/BookSortTitlePrefixHelper.cs b/API/Helpers/BookSortTitlePrefixHelper.cs new file mode 100644 index 000000000..c92df5d65 --- /dev/null +++ b/API/Helpers/BookSortTitlePrefixHelper.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace API.Helpers; + +/// +/// Responsible for parsing book titles "The man on the street" and removing the prefix -> "man on the street". +/// +/// This code is performance sensitive +public static class BookSortTitlePrefixHelper +{ + private static readonly Dictionary PrefixLookup; + private static readonly Dictionary> PrefixesByFirstChar; + + static BookSortTitlePrefixHelper() + { + var prefixes = new[] + { + // English + "the", "a", "an", + // Spanish + "el", "la", "los", "las", "un", "una", "unos", "unas", + // French + "le", "la", "les", "un", "une", "des", + // German + "der", "die", "das", "den", "dem", "ein", "eine", "einen", "einer", + // Italian + "il", "lo", "la", "gli", "le", "un", "uno", "una", + // Portuguese + "o", "a", "os", "as", "um", "uma", "uns", "umas", + // Russian (transliterated common ones) + "в", "на", "с", "к", "от", "для", + }; + + // Build lookup structures + PrefixLookup = new Dictionary(prefixes.Length, StringComparer.OrdinalIgnoreCase); + PrefixesByFirstChar = new Dictionary>(); + + foreach (var prefix in prefixes) + { + PrefixLookup[prefix] = 1; + + var firstChar = char.ToLowerInvariant(prefix[0]); + if (!PrefixesByFirstChar.TryGetValue(firstChar, out var list)) + { + list = []; + PrefixesByFirstChar[firstChar] = list; + } + list.Add(prefix); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ReadOnlySpan GetSortTitle(ReadOnlySpan title) + { + if (title.IsEmpty) return title; + + // Fast detection of script type by first character + var firstChar = title[0]; + + // CJK Unicode ranges - no processing needed for most cases + if ((firstChar >= 0x4E00 && firstChar <= 0x9FFF) || // CJK Unified + (firstChar >= 0x3040 && firstChar <= 0x309F) || // Hiragana + (firstChar >= 0x30A0 && firstChar <= 0x30FF)) // Katakana + { + return title; + } + + var firstSpaceIndex = title.IndexOf(' '); + if (firstSpaceIndex <= 0) return title; + + var potentialPrefix = title.Slice(0, firstSpaceIndex); + + // Fast path: check if first character could match any prefix + firstChar = char.ToLowerInvariant(potentialPrefix[0]); + if (!PrefixesByFirstChar.ContainsKey(firstChar)) + return title; + + // Only do the expensive lookup if first character matches + if (PrefixLookup.ContainsKey(potentialPrefix.ToString())) + { + var remainder = title.Slice(firstSpaceIndex + 1); + return remainder.IsEmpty ? title : remainder; + } + + return title; + } + + /// + /// Removes the sort prefix + /// + /// + /// + public static string GetSortTitle(string title) + { + var result = GetSortTitle(title.AsSpan()); + + return result.ToString(); + } +} diff --git a/API/Helpers/Builders/AppUserBuilder.cs b/API/Helpers/Builders/AppUserBuilder.cs index bc044c301..7ffac355e 100644 --- a/API/Helpers/Builders/AppUserBuilder.cs +++ b/API/Helpers/Builders/AppUserBuilder.cs @@ -21,7 +21,7 @@ public class AppUserBuilder : IEntityBuilder ApiKey = HashUtil.ApiKey(), UserPreferences = new AppUserPreferences { - Theme = theme ?? Seed.DefaultThemes.First() + Theme = theme ?? Seed.DefaultThemes.First(), }, ReadingLists = new List(), Bookmarks = new List(), @@ -31,7 +31,8 @@ public class AppUserBuilder : IEntityBuilder Devices = new List(), Id = 0, DashboardStreams = new List(), - SideNavStreams = new List() + SideNavStreams = new List(), + ReadingProfiles = [], }; } @@ -61,4 +62,10 @@ public class AppUserBuilder : IEntityBuilder return this; } + public AppUserBuilder WithRole(string role) + { + _appUser.UserRoles ??= new List(); + _appUser.UserRoles.Add(new AppUserRole() {Role = new AppRole() {Name = role}}); + return this; + } } diff --git a/API/Helpers/Builders/AppUserChapterRatingBuilder.cs b/API/Helpers/Builders/AppUserChapterRatingBuilder.cs new file mode 100644 index 000000000..b5deb9228 --- /dev/null +++ b/API/Helpers/Builders/AppUserChapterRatingBuilder.cs @@ -0,0 +1,40 @@ +#nullable enable +using System; +using API.Entities; + +namespace API.Helpers.Builders; + +public class ChapterRatingBuilder : IEntityBuilder +{ + private readonly AppUserChapterRating _rating; + public AppUserChapterRating Build() => _rating; + + public ChapterRatingBuilder(AppUserChapterRating? rating = null) + { + _rating = rating ?? new AppUserChapterRating(); + } + + public ChapterRatingBuilder WithSeriesId(int seriesId) + { + _rating.SeriesId = seriesId; + return this; + } + + public ChapterRatingBuilder WithChapterId(int chapterId) + { + _rating.ChapterId = chapterId; + return this; + } + + public ChapterRatingBuilder WithRating(int rating) + { + _rating.Rating = Math.Clamp(rating, 0, 5); + return this; + } + + public ChapterRatingBuilder WithBody(string body) + { + _rating.Review = body; + return this; + } +} diff --git a/API/Helpers/Builders/AppUserReadingProfileBuilder.cs b/API/Helpers/Builders/AppUserReadingProfileBuilder.cs new file mode 100644 index 000000000..26da5fd86 --- /dev/null +++ b/API/Helpers/Builders/AppUserReadingProfileBuilder.cs @@ -0,0 +1,54 @@ +using API.Entities; +using API.Entities.Enums; +using API.Extensions; + +namespace API.Helpers.Builders; + +public class AppUserReadingProfileBuilder +{ + private readonly AppUserReadingProfile _profile; + + public AppUserReadingProfile Build() => _profile; + + /// + /// The profile's kind will be unless overwritten with + /// + /// + public AppUserReadingProfileBuilder(int userId) + { + _profile = new AppUserReadingProfile + { + AppUserId = userId, + Kind = ReadingProfileKind.User, + SeriesIds = [], + LibraryIds = [] + }; + } + + public AppUserReadingProfileBuilder WithSeries(Series series) + { + _profile.SeriesIds.Add(series.Id); + return this; + } + + public AppUserReadingProfileBuilder WithLibrary(Library library) + { + _profile.LibraryIds.Add(library.Id); + return this; + } + + public AppUserReadingProfileBuilder WithKind(ReadingProfileKind kind) + { + _profile.Kind = kind; + return this; + } + + public AppUserReadingProfileBuilder WithName(string name) + { + _profile.Name = name; + _profile.NormalizedName = name.ToNormalized(); + return this; + } + + +} diff --git a/API/Helpers/Builders/ChapterBuilder.cs b/API/Helpers/Builders/ChapterBuilder.cs index 4d09a7abf..d9976d92a 100644 --- a/API/Helpers/Builders/ChapterBuilder.cs +++ b/API/Helpers/Builders/ChapterBuilder.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using API.Entities; using API.Entities.Enums; +using API.Entities.Person; using API.Services.Tasks.Scanner.Parser; namespace API.Helpers.Builders; @@ -24,7 +25,7 @@ public class ChapterBuilder : IEntityBuilder MinNumber = Parser.MinNumberFromRange(number), MaxNumber = Parser.MaxNumberFromRange(number), SortOrder = Parser.MinNumberFromRange(number), - Files = new List(), + Files = [], Pages = 1, CreatedUtc = DateTime.UtcNow }; @@ -38,9 +39,9 @@ public class ChapterBuilder : IEntityBuilder return builder.WithNumber(Parser.RemoveExtensionIfSupported(info.Chapters)!) .WithRange(specialTreatment ? info.Filename : info.Chapters) - .WithTitle((specialTreatment && info.Format == MangaFormat.Epub) + .WithTitle(specialTreatment && info.Format is MangaFormat.Epub or MangaFormat.Pdf ? info.Title - : specialTitle) + : specialTitle ?? string.Empty) .WithIsSpecial(specialTreatment); } @@ -155,4 +156,24 @@ public class ChapterBuilder : IEntityBuilder return this; } + + public ChapterBuilder WithTags(IList tags) + { + _chapter.Tags ??= []; + foreach (var tag in tags) + { + _chapter.Tags.Add(tag); + } + return this; + } + + public ChapterBuilder WithGenres(IList genres) + { + _chapter.Genres ??= []; + foreach (var genre in genres) + { + _chapter.Genres.Add(genre); + } + return this; + } } diff --git a/API/Helpers/Builders/ExternalSeriesMetadataBuilder.cs b/API/Helpers/Builders/ExternalSeriesMetadataBuilder.cs new file mode 100644 index 000000000..e716f5927 --- /dev/null +++ b/API/Helpers/Builders/ExternalSeriesMetadataBuilder.cs @@ -0,0 +1,26 @@ +using System; +using API.Entities.Metadata; + +namespace API.Helpers.Builders; + +public class ExternalSeriesMetadataBuilder : IEntityBuilder +{ + private readonly ExternalSeriesMetadata _metadata; + public ExternalSeriesMetadata Build() => _metadata; + + public ExternalSeriesMetadataBuilder() + { + _metadata = new ExternalSeriesMetadata(); + } + + /// + /// -1 for not set, Range 0 - 100 + /// + /// + /// + public ExternalSeriesMetadataBuilder WithAverageExternalRating(int rating) + { + _metadata.AverageExternalRating = Math.Clamp(rating, -1, 100); + return this; + } +} diff --git a/API/Helpers/Builders/GenreBuilder.cs b/API/Helpers/Builders/GenreBuilder.cs index 69e68f6c1..9b2f1590e 100644 --- a/API/Helpers/Builders/GenreBuilder.cs +++ b/API/Helpers/Builders/GenreBuilder.cs @@ -16,14 +16,14 @@ public class GenreBuilder : IEntityBuilder { Title = name.Trim().SentenceCase(), NormalizedTitle = name.ToNormalized(), - Chapters = new List(), - SeriesMetadatas = new List() + Chapters = [], + SeriesMetadatas = [] }; } public GenreBuilder WithSeriesMetadata(SeriesMetadata seriesMetadata) { - _genre.SeriesMetadatas ??= new List(); + _genre.SeriesMetadatas ??= []; _genre.SeriesMetadatas.Add(seriesMetadata); return this; } diff --git a/API/Helpers/Builders/KoreaderBookDtoBuilder.cs b/API/Helpers/Builders/KoreaderBookDtoBuilder.cs new file mode 100644 index 000000000..debbe0347 --- /dev/null +++ b/API/Helpers/Builders/KoreaderBookDtoBuilder.cs @@ -0,0 +1,46 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using API.DTOs.Koreader; + +namespace API.Helpers.Builders; + +public class KoreaderBookDtoBuilder : IEntityBuilder +{ + private readonly KoreaderBookDto _dto; + public KoreaderBookDto Build() => _dto; + + public KoreaderBookDtoBuilder(string documentHash) + { + _dto = new KoreaderBookDto() + { + Document = documentHash, + Device = "Kavita" + }; + } + + public KoreaderBookDtoBuilder WithDocument(string documentHash) + { + _dto.Document = documentHash; + return this; + } + + public KoreaderBookDtoBuilder WithProgress(string progress) + { + _dto.Progress = progress; + return this; + } + + public KoreaderBookDtoBuilder WithPercentage(int? pageNum, int pages) + { + _dto.Percentage = (pageNum ?? 0) / (float) pages; + return this; + } + + public KoreaderBookDtoBuilder WithDeviceId(string installId, int userId) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(installId + userId)); + _dto.Device_id = Convert.ToHexString(hash); + return this; + } +} diff --git a/API/Helpers/Builders/LibraryBuilder.cs b/API/Helpers/Builders/LibraryBuilder.cs index 1cfd529a1..950c5d3d2 100644 --- a/API/Helpers/Builders/LibraryBuilder.cs +++ b/API/Helpers/Builders/LibraryBuilder.cs @@ -20,8 +20,26 @@ public class LibraryBuilder : IEntityBuilder Series = new List(), Folders = new List(), AppUsers = new List(), - AllowScrobbling = type is LibraryType.LightNovel or LibraryType.Manga + AllowScrobbling = type is LibraryType.LightNovel or LibraryType.Manga, + LibraryFileTypes = new List() }; + + _library.LibraryFileTypes.Add(new LibraryFileTypeGroup() + { + FileTypeGroup = FileTypeGroup.Archive + }); + _library.LibraryFileTypes.Add(new LibraryFileTypeGroup() + { + FileTypeGroup = FileTypeGroup.Epub + }); + _library.LibraryFileTypes.Add(new LibraryFileTypeGroup() + { + FileTypeGroup = FileTypeGroup.Images + }); + _library.LibraryFileTypes.Add(new LibraryFileTypeGroup() + { + FileTypeGroup = FileTypeGroup.Pdf + }); } public LibraryBuilder(Library library) @@ -86,7 +104,19 @@ public class LibraryBuilder : IEntityBuilder return this; } - public LibraryBuilder WIthAllowScrobbling(bool allowScrobbling) + public LibraryBuilder WithAllowMetadataMatching(bool allow) + { + _library.AllowMetadataMatching = allow; + return this; + } + + public LibraryBuilder WithEnableMetadata(bool enable) + { + _library.EnableMetadata = enable; + return this; + } + + public LibraryBuilder WithAllowScrobbling(bool allowScrobbling) { _library.AllowScrobbling = allowScrobbling; return this; diff --git a/API/Helpers/Builders/MangaFileBuilder.cs b/API/Helpers/Builders/MangaFileBuilder.cs index 5387a3349..ea3ff0c6d 100644 --- a/API/Helpers/Builders/MangaFileBuilder.cs +++ b/API/Helpers/Builders/MangaFileBuilder.cs @@ -60,4 +60,17 @@ public class MangaFileBuilder : IEntityBuilder _mangaFile.Id = Math.Max(id, 0); return this; } + + /// + /// Generate the Hash on the underlying file + /// + /// Only applicable to Epubs + public MangaFileBuilder WithHash() + { + if (_mangaFile.Format != MangaFormat.Epub) return this; + + _mangaFile.KoreaderHash = KoreaderHelper.HashContents(_mangaFile.FilePath); + + return this; + } } diff --git a/API/Helpers/Builders/MediaErrorBuilder.cs b/API/Helpers/Builders/MediaErrorBuilder.cs index 56b19ba33..4d0f7f3a0 100644 --- a/API/Helpers/Builders/MediaErrorBuilder.cs +++ b/API/Helpers/Builders/MediaErrorBuilder.cs @@ -1,5 +1,6 @@ using System.IO; using API.Entities; +using API.Services.Tasks.Scanner.Parser; namespace API.Helpers.Builders; @@ -12,7 +13,7 @@ public class MediaErrorBuilder : IEntityBuilder { _mediaError = new MediaError() { - FilePath = filePath, + FilePath = Parser.NormalizePath(filePath), Extension = Path.GetExtension(filePath).Replace(".", string.Empty).ToUpperInvariant() }; } diff --git a/API/Helpers/Builders/PersonAliasBuilder.cs b/API/Helpers/Builders/PersonAliasBuilder.cs new file mode 100644 index 000000000..e54ea8975 --- /dev/null +++ b/API/Helpers/Builders/PersonAliasBuilder.cs @@ -0,0 +1,19 @@ +using API.Entities.Person; +using API.Extensions; + +namespace API.Helpers.Builders; + +public class PersonAliasBuilder : IEntityBuilder +{ + private readonly PersonAlias _alias; + public PersonAlias Build() => _alias; + + public PersonAliasBuilder(string name) + { + _alias = new PersonAlias() + { + Alias = name.Trim(), + NormalizedAlias = name.ToNormalized(), + }; + } +} diff --git a/API/Helpers/Builders/PersonBuilder.cs b/API/Helpers/Builders/PersonBuilder.cs index 2bbdfa744..afd0c84af 100644 --- a/API/Helpers/Builders/PersonBuilder.cs +++ b/API/Helpers/Builders/PersonBuilder.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; -using API.Entities; -using API.Entities.Enums; -using API.Entities.Metadata; +using System.Linq; +using API.Entities.Person; using API.Extensions; namespace API.Helpers.Builders; @@ -33,6 +32,20 @@ public class PersonBuilder : IEntityBuilder return this; } + public PersonBuilder WithAlias(string alias) + { + if (_person.Aliases.Any(a => a.NormalizedAlias.Equals(alias.ToNormalized()))) + { + return this; + } + + _person.Aliases.Add(new PersonAliasBuilder(alias).Build()); + + return this; + } + + + public PersonBuilder WithSeriesMetadata(SeriesMetadataPeople seriesMetadataPeople) { _person.SeriesMetadataPeople.Add(seriesMetadataPeople); diff --git a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs index 6a8e70bde..3da217b9f 100644 --- a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs +++ b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs @@ -2,14 +2,15 @@ using API.DTOs; using API.DTOs.Scrobbling; using API.Entities; +using API.Extensions; using API.Services.Plus; namespace API.Helpers.Builders; -public class PlusSeriesDtoBuilder : IEntityBuilder +public class PlusSeriesDtoBuilder : IEntityBuilder { - private readonly PlusSeriesDto _seriesDto; - public PlusSeriesDto Build() => _seriesDto; + private readonly PlusSeriesRequestDto _seriesRequestDto; + public PlusSeriesRequestDto Build() => _seriesRequestDto; /// /// This must be a FULL Series @@ -17,9 +18,9 @@ public class PlusSeriesDtoBuilder : IEntityBuilder /// public PlusSeriesDtoBuilder(Series series) { - _seriesDto = new PlusSeriesDto() + _seriesRequestDto = new PlusSeriesRequestDto() { - MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type), + MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), SeriesName = series.Name, AltSeriesName = series.LocalizedName, AniListId = ScrobblingService.ExtractId(series.Metadata.WebLinks, diff --git a/API/Helpers/Builders/SeriesBuilder.cs b/API/Helpers/Builders/SeriesBuilder.cs index e11de7636..96e820659 100644 --- a/API/Helpers/Builders/SeriesBuilder.cs +++ b/API/Helpers/Builders/SeriesBuilder.cs @@ -21,11 +21,13 @@ public class SeriesBuilder : IEntityBuilder _series = new Series() { Name = name, + LocalizedName = name.ToNormalized(), + NormalizedLocalizedName = name.ToNormalized(), + OriginalName = name, SortName = name, NormalizedName = name.ToNormalized(), - NormalizedLocalizedName = name.ToNormalized(), Metadata = new SeriesMetadataBuilder() .WithPublicationStatus(PublicationStatus.OnGoing) .Build(), @@ -39,14 +41,25 @@ public class SeriesBuilder : IEntityBuilder /// /// /// - public SeriesBuilder WithLocalizedName(string localizedName) + public SeriesBuilder WithLocalizedName(string localizedName, bool lockStatus = false) { + // Why is this here? if (string.IsNullOrEmpty(localizedName)) { localizedName = _series.Name; } + _series.LocalizedName = localizedName; _series.NormalizedLocalizedName = localizedName.ToNormalized(); + _series.LocalizedNameLocked = lockStatus; + return this; + } + + public SeriesBuilder WithLocalizedNameAllowEmpty(string localizedName, bool lockStatus = false) + { + _series.LocalizedName = localizedName; + _series.NormalizedLocalizedName = localizedName.ToNormalized(); + _series.LocalizedNameLocked = lockStatus; return this; } @@ -98,4 +111,23 @@ public class SeriesBuilder : IEntityBuilder _series.Metadata.PublicationStatus = status; return this; } + + public SeriesBuilder WithExternalMetadata(ExternalSeriesMetadata metadata) + { + _series.ExternalSeriesMetadata = metadata; + return this; + } + + + public SeriesBuilder WithRelationship(int targetSeriesId, RelationKind kind) + { + _series.Relations ??= []; + _series.Relations.Add(new SeriesRelation() + { + RelationKind = kind, + TargetSeriesId = targetSeriesId + }); + + return this; + } } diff --git a/API/Helpers/Builders/SeriesMetadataBuilder.cs b/API/Helpers/Builders/SeriesMetadataBuilder.cs index 703957c86..462bc4455 100644 --- a/API/Helpers/Builders/SeriesMetadataBuilder.cs +++ b/API/Helpers/Builders/SeriesMetadataBuilder.cs @@ -1,7 +1,9 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; namespace API.Helpers.Builders; @@ -21,12 +23,15 @@ public class SeriesMetadataBuilder : IEntityBuilder }; } + [Obsolete] public SeriesMetadataBuilder WithCollectionTag(CollectionTag tag) { _seriesMetadata.CollectionTags ??= new List(); _seriesMetadata.CollectionTags.Add(tag); return this; } + + [Obsolete] public SeriesMetadataBuilder WithCollectionTags(IList tags) { if (tags == null) return this; @@ -34,15 +39,18 @@ public class SeriesMetadataBuilder : IEntityBuilder _seriesMetadata.CollectionTags = tags; return this; } - public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status) + + public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status, bool lockState = false) { _seriesMetadata.PublicationStatus = status; + _seriesMetadata.PublicationStatusLocked = lockState; return this; } - public SeriesMetadataBuilder WithAgeRating(AgeRating rating) + public SeriesMetadataBuilder WithAgeRating(AgeRating rating, bool lockState = false) { _seriesMetadata.AgeRating = rating; + _seriesMetadata.AgeRatingLocked = lockState; return this; } @@ -55,7 +63,68 @@ public class SeriesMetadataBuilder : IEntityBuilder Person = person, SeriesMetadata = _seriesMetadata, }); + return this; + } + public SeriesMetadataBuilder WithLanguage(string languageCode) + { + _seriesMetadata.Language = languageCode; + return this; + } + + public SeriesMetadataBuilder WithReleaseYear(int year, bool lockStatus = false) + { + _seriesMetadata.ReleaseYear = year; + _seriesMetadata.ReleaseYearLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithSummary(string summary, bool lockStatus = false) + { + _seriesMetadata.Summary = summary; + _seriesMetadata.SummaryLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithGenre(Genre genre, bool lockStatus = false) + { + _seriesMetadata.Genres ??= []; + _seriesMetadata.Genres.Add(genre); + _seriesMetadata.GenresLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithGenres(List genres, bool lockStatus = false) + { + _seriesMetadata.Genres = genres; + _seriesMetadata.GenresLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithTag(Tag tag, bool lockStatus = false) + { + _seriesMetadata.Tags ??= []; + _seriesMetadata.Tags.Add(tag); + _seriesMetadata.TagsLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithTags(List tags, bool lockStatus = false) + { + _seriesMetadata.Tags = tags; + _seriesMetadata.TagsLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithMaxCount(int count) + { + _seriesMetadata.MaxCount = count; + return this; + } + + public SeriesMetadataBuilder WithTotalCount(int count) + { + _seriesMetadata.TotalCount = count; return this; } } diff --git a/API/Helpers/Builders/TagBuilder.cs b/API/Helpers/Builders/TagBuilder.cs index 084171f54..623587fd1 100644 --- a/API/Helpers/Builders/TagBuilder.cs +++ b/API/Helpers/Builders/TagBuilder.cs @@ -16,8 +16,8 @@ public class TagBuilder : IEntityBuilder { Title = name.Trim().SentenceCase(), NormalizedTitle = name.ToNormalized(), - Chapters = new List(), - SeriesMetadatas = new List() + Chapters = [], + SeriesMetadatas = [] }; } diff --git a/API/Helpers/Converters/FilterFieldValueConverter.cs b/API/Helpers/Converters/FilterFieldValueConverter.cs index 46012f256..631332f5f 100644 --- a/API/Helpers/Converters/FilterFieldValueConverter.cs +++ b/API/Helpers/Converters/FilterFieldValueConverter.cs @@ -100,7 +100,8 @@ public static class FilterFieldValueConverter .ToList(), FilterField.WantToRead => bool.Parse(value), FilterField.ReadProgress => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(), - FilterField.ReadingDate => DateTime.Parse(value), + FilterField.ReadingDate => DateTime.Parse(value, CultureInfo.InvariantCulture), + FilterField.ReadLast => int.Parse(value), FilterField.Formats => value.Split(',') .Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x)) .ToList(), diff --git a/API/Helpers/Converters/PersonFilterFieldValueConverter.cs b/API/Helpers/Converters/PersonFilterFieldValueConverter.cs new file mode 100644 index 000000000..822ce105a --- /dev/null +++ b/API/Helpers/Converters/PersonFilterFieldValueConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; + +namespace API.Helpers.Converters; + +public static class PersonFilterFieldValueConverter +{ + public static object ConvertValue(PersonFilterField field, string value) + { + return field switch + { + PersonFilterField.Name => value, + PersonFilterField.Role => ParsePersonRoles(value), + PersonFilterField.SeriesCount => int.Parse(value), + PersonFilterField.ChapterCount => int.Parse(value), + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not supported") + }; + } + + private static IList ParsePersonRoles(string value) + { + if (string.IsNullOrEmpty(value)) return []; + + return value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(v => Enum.Parse(v.Trim())) + .ToList(); + } +} diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 1180e4087..7adb5228f 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; @@ -33,7 +34,7 @@ public class ServerSettingConverter : ITypeConverter, destination.LoggingLevel = row.Value; break; case ServerSettingKey.Port: - destination.Port = int.Parse(row.Value); + destination.Port = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.IpAddresses: destination.IpAddresses = row.Value; @@ -53,11 +54,8 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.InstallVersion: destination.InstallVersion = row.Value; break; - case ServerSettingKey.EncodeMediaAs: - destination.EncodeMediaAs = Enum.Parse(row.Value); - break; case ServerSettingKey.TotalBackups: - destination.TotalBackups = int.Parse(row.Value); + destination.TotalBackups = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.InstallId: destination.InstallId = row.Value; @@ -66,33 +64,36 @@ public class ServerSettingConverter : ITypeConverter, destination.EnableFolderWatching = bool.Parse(row.Value); break; case ServerSettingKey.TotalLogs: - destination.TotalLogs = int.Parse(row.Value); + destination.TotalLogs = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.HostName: destination.HostName = row.Value; break; case ServerSettingKey.CacheSize: - destination.CacheSize = long.Parse(row.Value); + destination.CacheSize = long.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.OnDeckProgressDays: - destination.OnDeckProgressDays = int.Parse(row.Value); + destination.OnDeckProgressDays = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.OnDeckUpdateDays: - destination.OnDeckUpdateDays = int.Parse(row.Value); + destination.OnDeckUpdateDays = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.CoverImageSize: destination.CoverImageSize = Enum.Parse(row.Value); break; + case ServerSettingKey.EncodeMediaAs: + destination.EncodeMediaAs = Enum.Parse(row.Value); + break; case ServerSettingKey.BackupDirectory: destination.BookmarksDirectory = row.Value; break; case ServerSettingKey.EmailHost: destination.SmtpConfig ??= new SmtpConfigDto(); - destination.SmtpConfig.Host = row.Value; + destination.SmtpConfig.Host = row.Value ?? string.Empty; break; case ServerSettingKey.EmailPort: destination.SmtpConfig ??= new SmtpConfigDto(); - destination.SmtpConfig.Port = string.IsNullOrEmpty(row.Value) ? 0 : int.Parse(row.Value); + destination.SmtpConfig.Port = string.IsNullOrEmpty(row.Value) ? 0 : int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.EmailAuthPassword: destination.SmtpConfig ??= new SmtpConfigDto(); @@ -116,18 +117,25 @@ public class ServerSettingConverter : ITypeConverter, break; case ServerSettingKey.EmailSizeLimit: destination.SmtpConfig ??= new SmtpConfigDto(); - destination.SmtpConfig.SizeLimit = int.Parse(row.Value); + destination.SmtpConfig.SizeLimit = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.EmailCustomizedTemplates: destination.SmtpConfig ??= new SmtpConfigDto(); destination.SmtpConfig.CustomizedTemplates = bool.Parse(row.Value); break; case ServerSettingKey.FirstInstallDate: - destination.FirstInstallDate = DateTime.Parse(row.Value); + destination.FirstInstallDate = DateTime.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.FirstInstallVersion: destination.FirstInstallVersion = row.Value; break; + case ServerSettingKey.LicenseKey: + case ServerSettingKey.EnableAuthentication: + case ServerSettingKey.EmailServiceUrl: + case ServerSettingKey.ConvertBookmarkToWebP: + case ServerSettingKey.ConvertCoverToWebP: + default: + break; } } diff --git a/API/Helpers/DayOfWeekHelper.cs b/API/Helpers/DayOfWeekHelper.cs new file mode 100644 index 000000000..10cdb4170 --- /dev/null +++ b/API/Helpers/DayOfWeekHelper.cs @@ -0,0 +1,18 @@ +using System; + +namespace API.Helpers; + +public static class DayOfWeekHelper +{ + private static readonly Random Rnd = new(); + + /// + /// Returns a random DayOfWeek value. + /// + /// A randomly selected DayOfWeek. + public static DayOfWeek Random() + { + var values = Enum.GetValues(); + return (DayOfWeek)values.GetValue(Rnd.Next(values.Length))!; + } +} diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index b11915053..8580178d9 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -12,12 +12,19 @@ using Microsoft.EntityFrameworkCore; namespace API.Helpers; #nullable enable + public static class GenreHelper { + public static async Task UpdateChapterGenres(Chapter chapter, IEnumerable genreNames, IUnitOfWork unitOfWork) { // Normalize genre names once and store them in a hash set for quick lookups - var normalizedGenresToAdd = new HashSet(genreNames.Select(g => g.ToNormalized())); + var normalizedToOriginal = genreNames + .Select(g => new { Original = g, Normalized = g.ToNormalized() }) + .GroupBy(x => x.Normalized) + .ToDictionary(g => g.Key, g => g.First().Original); + + var normalizedGenresToAdd = new HashSet(normalizedToOriginal.Keys); // Remove genres that are no longer in the new list var genresToRemove = chapter.Genres @@ -40,7 +47,7 @@ public static class GenreHelper // Find missing genres that are not in the database var missingGenres = normalizedGenresToAdd .Where(nt => !existingGenreTitles.ContainsKey(nt)) - .Select(title => new GenreBuilder(title).Build()) + .Select(nt => new GenreBuilder(normalizedToOriginal[nt]).Build()) .ToList(); // Add missing genres to the database @@ -71,13 +78,19 @@ public static class GenreHelper public static void UpdateGenreList(ICollection? existingGenres, Series series, IReadOnlyCollection newGenres, Action handleAdd, Action onModified) + { + UpdateGenreList(existingGenres.DefaultIfEmpty().Select(t => t.Title).ToList(), series, newGenres, handleAdd, onModified); + } + + public static void UpdateGenreList(ICollection? existingGenres, Series series, + IReadOnlyCollection newGenres, Action handleAdd, Action onModified) { if (existingGenres == null) return; var isModified = false; // Convert tags and existing genres to hash sets for quick lookups by normalized title - var tagSet = new HashSet(existingGenres.Select(t => t.Title.ToNormalized())); + var tagSet = new HashSet(existingGenres.Select(t => t.ToNormalized())); var genreSet = new HashSet(series.Metadata.Genres.Select(g => g.NormalizedTitle)); // Remove tags that are no longer present in the input tags @@ -97,7 +110,7 @@ public static class GenreHelper // Add new tags from the input list foreach (var tagDto in existingGenres) { - var normalizedTitle = tagDto.Title.ToNormalized(); + var normalizedTitle = tagDto.ToNormalized(); if (genreSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres @@ -107,7 +120,7 @@ public static class GenreHelper } else { - handleAdd(new GenreBuilder(tagDto.Title).Build()); // Add new genre if not found + handleAdd(new GenreBuilder(tagDto).Build()); // Add new genre if not found } isModified = true; } diff --git a/API/Helpers/JwtHelper.cs b/API/Helpers/JwtHelper.cs new file mode 100644 index 000000000..0f9219804 --- /dev/null +++ b/API/Helpers/JwtHelper.cs @@ -0,0 +1,45 @@ +using System; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; + +namespace API.Helpers; + +public static class JwtHelper +{ + /// + /// Extracts the expiration date from a JWT token. + /// + public static DateTime GetTokenExpiry(string jwtToken) + { + if (string.IsNullOrEmpty(jwtToken)) + return DateTime.MinValue; + + // Parse the JWT and extract the expiry claim + var jwtHandler = new JwtSecurityTokenHandler(); + var token = jwtHandler.ReadJwtToken(jwtToken); + return token.ValidTo; + + // var exp = token.Claims.FirstOrDefault(c => c.Type == "exp")?.Value; + // + // if (long.TryParse(exp, CultureInfo.InvariantCulture, out var expSeconds)) + // { + // return DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcDateTime; + // } + // + // + // + // return DateTime.MinValue; + } + + /// + /// Checks if a JWT token is valid based on its expiry date. + /// + public static bool IsTokenValid(string jwtToken) + { + if (string.IsNullOrEmpty(jwtToken)) return false; + + var expiry = GetTokenExpiry(jwtToken); + return expiry > DateTime.UtcNow; + } +} diff --git a/API/Helpers/KoreaderHelper.cs b/API/Helpers/KoreaderHelper.cs new file mode 100644 index 000000000..e779cd911 --- /dev/null +++ b/API/Helpers/KoreaderHelper.cs @@ -0,0 +1,113 @@ +using API.DTOs.Progress; +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using API.Services.Tasks.Scanner.Parser; + +namespace API.Helpers; + +/// +/// All things related to Koreader +/// +/// Original developer: https://github.com/MFDeAngelo +public static class KoreaderHelper +{ + /// + /// Hashes the document according to a custom Koreader hashing algorithm. + /// Look at the util.partialMD5 method in the attached link. + /// Note: Only applies to epub files + /// + /// The hashing algorithm is relatively quick as it only hashes ~10,000 bytes for the biggest of files. + /// + /// The path to the file to hash + public static string HashContents(string filePath) + { + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath) || !Parser.IsEpub(filePath)) + { + return null; + } + + using var file = File.OpenRead(filePath); + + const int step = 1024; + const int size = 1024; + var md5 = MD5.Create(); + var buffer = new byte[size]; + + for (var i = -1; i < 10; i++) + { + file.Position = step << 2 * i; + var bytesRead = file.Read(buffer, 0, size); + if (bytesRead > 0) + { + md5.TransformBlock(buffer, 0, bytesRead, buffer, 0); + } + else + { + break; + } + } + + file.Close(); + md5.TransformFinalBlock([], 0, 0); + + return md5.Hash == null ? null : Convert.ToHexString(md5.Hash).ToUpper(); + } + + /// + /// Koreader can identify documents based on contents or title. + /// For now, we only support by contents. + /// + public static string HashTitle(string filePath) + { + var fileName = Path.GetFileName(filePath); + var fileNameBytes = Encoding.ASCII.GetBytes(fileName); + var bytes = MD5.HashData(fileNameBytes); + + return Convert.ToHexString(bytes); + } + + public static void UpdateProgressDto(ProgressDto progress, string koreaderPosition) + { + var path = koreaderPosition.Split('/'); + if (path.Length < 6) + { + return; + } + + var docNumber = path[2].Replace("DocFragment[", string.Empty).Replace("]", string.Empty); + progress.PageNum = int.Parse(docNumber) - 1; + var lastTag = path[5].ToUpper(); + + if (lastTag == "A") + { + progress.BookScrollId = null; + } + else + { + // The format that Kavita accepts as a progress string. It tells Kavita where Koreader last left off. + progress.BookScrollId = $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/{lastTag}"; + } + } + + + public static string GetKoreaderPosition(ProgressDto progressDto) + { + string lastTag; + var koreaderPageNumber = progressDto.PageNum + 1; + + if (string.IsNullOrEmpty(progressDto.BookScrollId)) + { + lastTag = "a"; + } + else + { + var tokens = progressDto.BookScrollId.Split('/'); + lastTag = tokens[^1].ToLower(); + } + + // The format that Koreader accepts as a progress string. It tells Koreader where Kavita last left off. + return $"/body/DocFragment[{koreaderPageNumber}]/body/div/{lastTag}"; + } +} diff --git a/API/Helpers/LibraryTypeHelper.cs b/API/Helpers/LibraryTypeHelper.cs deleted file mode 100644 index 423d93f0e..000000000 --- a/API/Helpers/LibraryTypeHelper.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using API.DTOs.Scrobbling; -using API.Entities.Enums; - -namespace API.Helpers; -#nullable enable - -public static class LibraryTypeHelper -{ - public static MediaFormat GetFormat(LibraryType libraryType) - { - // TODO: Refactor this to an extension on LibraryType - return libraryType switch - { - LibraryType.Manga => MediaFormat.Manga, - LibraryType.Comic => MediaFormat.Comic, - LibraryType.LightNovel => MediaFormat.LightNovel, - LibraryType.Book => MediaFormat.LightNovel, - LibraryType.Image => MediaFormat.Manga, - LibraryType.ComicVine => MediaFormat.Comic, - _ => throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null) - }; - } -} diff --git a/API/Helpers/OrderableHelper.cs b/API/Helpers/OrderableHelper.cs index 3313ca658..d4ff89573 100644 --- a/API/Helpers/OrderableHelper.cs +++ b/API/Helpers/OrderableHelper.cs @@ -9,6 +9,8 @@ public static class OrderableHelper { public static void ReorderItems(List items, int itemId, int toPosition) { + if (toPosition < 0) throw new ArgumentException("toPosition cannot be less than 0"); + var item = items.Find(r => r.Id == itemId); if (item != null) { @@ -24,6 +26,8 @@ public static class OrderableHelper public static void ReorderItems(List items, int itemId, int toPosition) { + if (toPosition < 0) throw new ArgumentException("toPosition cannot be less than 0"); + var item = items.Find(r => r.Id == itemId); if (item != null && toPosition < items.Count) { @@ -48,10 +52,15 @@ public static class OrderableHelper public static void ReorderItems(List items, int readingListItemId, int toPosition) { if (toPosition < 0) throw new ArgumentException("toPosition cannot be less than 0"); + var item = items.Find(r => r.Id == readingListItemId); if (item != null) { items.Remove(item); + + // Ensure toPosition is within the new list bounds + toPosition = Math.Min(toPosition, items.Count); + items.Insert(toPosition, item); } diff --git a/API/Helpers/PdfComicInfoExtractor.cs b/API/Helpers/PdfComicInfoExtractor.cs new file mode 100644 index 000000000..ce74ae97d --- /dev/null +++ b/API/Helpers/PdfComicInfoExtractor.cs @@ -0,0 +1,146 @@ +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ +using System; +using API.Data.Metadata; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; +using Nager.ArticleNumber; +using System.Collections.Generic; +using System.Globalization; + +namespace API.Helpers; +#nullable enable + +public interface IPdfComicInfoExtractor +{ + ComicInfo? GetComicInfo(string filePath); +} + +/// +/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure. +/// +public class PdfComicInfoExtractor : IPdfComicInfoExtractor +{ + private readonly ILogger _logger; + private readonly IMediaErrorService _mediaErrorService; + private readonly string[] _pdfDateFormats = [ // PDF Spec 7.9.4 + "D:yyyyMMddHHmmsszzz:", "D:yyyyMMddHHmmss+", "D:yyyyMMddHHmmss", + "D:yyyyMMddHHmmzzz:", "D:yyyyMMddHHmm+", "D:yyyyMMddHHmm", + "D:yyyyMMddHHzzz:", "D:yyyyMMddHH+", "D:yyyyMMddHH", + "D:yyyyMMdd", "D:yyyyMM", "D:yyyy" + ]; + + public PdfComicInfoExtractor(ILogger logger, IMediaErrorService mediaErrorService) + { + _logger = logger; + _mediaErrorService = mediaErrorService; + } + + private static float? GetFloatFromText(string? text) + { + if (string.IsNullOrEmpty(text)) return null; + + if (float.TryParse(text, CultureInfo.InvariantCulture, out var value)) return value; + + return null; + } + + private DateTime? GetDateTimeFromText(string? text) + { + if (string.IsNullOrEmpty(text)) return null; + + // Dates stored in the XMP metadata stream (PDF Spec 14.3.2) + // are stored in ISO 8601 format, which is handled by C# out of the box + if (DateTime.TryParse(text, CultureInfo.InvariantCulture, out var date)) return date; + + // Dates stored in the document information directory (PDF Spec 14.3.3) + // are stored in a proprietary format (PDF Spec 7.9.4) that needs to be + // massaged slightly to be expressible by a DateTime format. + if (text[0] != 'D') { + text = "D:" + text; + } + text = text.Replace("'", ":"); + text = text.Replace("Z", "+"); + + foreach(var format in _pdfDateFormats) + { + if (DateTime.TryParseExact(text, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var pdfDate)) return pdfDate; + } + + return null; + } + + private static string? MaybeGetMetadata(Dictionary metadata, string key) + { + return metadata.TryGetValue(key, out var value) ? value : null; + } + + private ComicInfo? GetComicInfoFromMetadata(Dictionary metadata, string filePath) + { + var info = new ComicInfo(); + + var publicationDate = GetDateTimeFromText(MaybeGetMetadata(metadata, "CreationDate")); + + if (publicationDate != null) + { + info.Year = publicationDate.Value.Year; + info.Month = publicationDate.Value.Month; + info.Day = publicationDate.Value.Day; + } + + info.Summary = MaybeGetMetadata(metadata, "Summary") ?? string.Empty; + info.Publisher = MaybeGetMetadata(metadata, "Publisher") ?? string.Empty; + info.Writer = MaybeGetMetadata(metadata, "Author") ?? string.Empty; + info.Title = MaybeGetMetadata(metadata, "Title") ?? string.Empty; + info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty; + info.Genre = MaybeGetMetadata(metadata, "Subject") ?? string.Empty; + info.LanguageISO = BookService.ValidateLanguage(MaybeGetMetadata(metadata, "Language")); + info.Isbn = MaybeGetMetadata(metadata, "ISBN") ?? string.Empty; + + if (info.Isbn != string.Empty && !ArticleNumberHelper.IsValidIsbn10(info.Isbn) && !ArticleNumberHelper.IsValidIsbn13(info.Isbn)) + { + _logger.LogDebug("[BookService] {File} has an invalid ISBN number", filePath); + info.Isbn = string.Empty; + } + + info.UserRating = GetFloatFromText(MaybeGetMetadata(metadata, "UserRating")) ?? 0.0f; + info.Series = MaybeGetMetadata(metadata, "Series") ?? info.Title; + info.SeriesSort = info.Series; + info.Volume = MaybeGetMetadata(metadata, "Volume") ?? string.Empty; + + // If this is a single book and not a collection, set publication status to Completed + if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) + { + info.Count = 1; + } + + ComicInfo.CleanComicInfo(info); + + return info; + } + + public ComicInfo? GetComicInfo(string filePath) + { + try + { + var extractor = new PdfMetadataExtractor(_logger, filePath); + + return GetComicInfoFromMetadata(extractor.GetMetadata(), filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing PDF metadata for {File}", filePath); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception parsing PDF metadata", ex); + } + + return null; + } +} diff --git a/API/Helpers/PdfMetadataExtractor.cs b/API/Helpers/PdfMetadataExtractor.cs new file mode 100644 index 000000000..44327672b --- /dev/null +++ b/API/Helpers/PdfMetadataExtractor.cs @@ -0,0 +1,1637 @@ +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ + +using System; +using System.Collections.Generic; +using System.IO.Compression; +using System.Text; +using System.Xml; +using System.IO; +using Microsoft.Extensions.Logging; +using API.Services; + +namespace API.Helpers; +#nullable enable + +/// +/// Parse PDF file and try to extract as much metadata as possible. +/// Supports both text based XRef tables and compressed XRef streams (Deflate only). +/// Supports both UTF-16 and PDFDocEncoding for strings. +/// Lacks support for many PDF configurations that are theoretically possible, but should handle most common cases. +/// +public class PdfMetadataExtractorException : Exception +{ + public PdfMetadataExtractorException() + { + } + + public PdfMetadataExtractorException(string message) + : base(message) + { + } + + public PdfMetadataExtractorException(string message, Exception inner) + : base(message, inner) + { + } +} + +public interface IPdfMetadataExtractor +{ + Dictionary GetMetadata(); +} + +class PdfStringBuilder +{ + private readonly StringBuilder _builder = new(); + private bool _secondByte = false; + private byte _prevByte = 0; + private bool _isUnicode = false; + + // PDFDocEncoding defined in PDF Spec D.1 + + private readonly char[] _pdfDocMappingLow = + [ + '\u02D8', '\u02C7', '\u02C6', '\u02D9', '\u02DD', '\u02DB', '\u02DA', '\u02DC' + ]; + + private readonly char[] _pdfDocMappingHigh = + [ + '\u2022', '\u2020', '\u2021', '\u2026', '\u2014', '\u2013', '\u0192', '\u2044', + '\u2039', '\u203A', '\u2212', '\u2030', '\u201E', '\u201C', '\u201D', '\u2018', + '\u2019', '\u201A', '\u2122', '\uFB01', '\uFB02', '\u0141', '\u0152', '\u0160', + '\u0178', '\u017D', '\u0131', '\u0142', '\u0153', '\u0161', '\u017E', ' ', + '\u20AC' + ]; + + private void AppendPdfDocByte(byte b) + { + if (b >= 0x18 && b < 0x20) + { + _builder.Append(_pdfDocMappingLow[b - 0x18]); + } + else if (b >= 0x80 && b < 0xA1) + { + _builder.Append(_pdfDocMappingHigh[b - 0x80]); + } + else + { + _builder.Append((char)b); + } + } + + public void Append(char c) + { + _builder.Append(c); + } + + public void AppendByte(byte b) + { + // PDF Spec 7.9.2.1: Strings are either UTF-16BE or PDFDocEncoded + if (_builder.Length == 0 && !_isUnicode) + { + // Unicode strings are prefixed by a big endian BOM \uFEFF + if (_secondByte) + { + if (b == 0xFF) + { + _isUnicode = true; + _secondByte = false; + } + else + { + AppendPdfDocByte(_prevByte); + AppendPdfDocByte(b); + } + } + else if (!_secondByte && b == 0xFE) + { + _secondByte = true; + _prevByte = b; + } + else + { + AppendPdfDocByte(b); + } + } + else if (_isUnicode) + { + if (_secondByte) + { + _builder.Append((char)(((char)_prevByte) << 8 | (char)b)); + _secondByte = false; + } + else + { + _prevByte = b; + _secondByte = true; + } + } + else + { + AppendPdfDocByte(b); + } + } + + override public string ToString() + { + if (_builder.Length == 0 && _secondByte) + { + AppendPdfDocByte(_prevByte); + } + + return _builder.ToString(); + } +} + +internal class PdfLexer(Stream stream) +{ + private const int BufferSize = 1024; + private readonly byte[] _buffer = new byte[BufferSize]; + private int _pos = 0; + private int _valid = 0; + + public enum TokenType + { + None, + Bool, + Int, + Double, + Name, + String, + ArrayStart, + ArrayEnd, + DictionaryStart, + DictionaryEnd, + StreamStart, + StreamEnd, + ObjectStart, + ObjectEnd, + ObjectRef, + Keyword, + Newline, + } + + public struct Token(TokenType type, object value) + { + public TokenType Type = type; + public object Value = value; + } + + public Token NextToken(bool reportNewlines = false) + { + while (true) + { + switch ((char)NextByte()) + { + case '\n' when reportNewlines: + return new Token(TokenType.Newline, true); + + case '\r' when reportNewlines: + if (NextByte() != '\n') + { + PutBack(); + } + return new Token(TokenType.Newline, true); + + case ' ': + case '\x00': + case '\t': + case '\n': + case '\f': + case '\r': + continue; // Skip whitespace + + case '%': + SkipComment(); + continue; + + case '+': + case '-': + case '.': + case >= '0' and <= '9': + return ScanNumber(); + + case '/': + return ScanName(); + + case '(': + return ScanString(); + + case '[': + return new Token(TokenType.ArrayStart, true); + + case ']': + return new Token(TokenType.ArrayEnd, true); + + case '<': + if (NextByte() == '<') + { + return new Token(TokenType.DictionaryStart, true); + } + else + { + PutBack(); + return ScanHexString(); + } + case '>': + ExpectByte((byte)'>'); + + return new Token(TokenType.DictionaryEnd, true); + + case >= 'a' and <= 'z': + case >= 'A' and <= 'Z': + return ScanKeyword(); + + default: + throw new PdfMetadataExtractorException("Unexpected byte, got {LastByte()}"); + } + } + } + + public void ResetBuffer() + { + _pos = 0; + _valid = 0; + } + + public bool TestByte(byte expected) + { + var result = NextByte() == expected; + + PutBack(); + + return result; + } + + public void ExpectNewline() + { + while (true) + { + var b = NextByte(); + switch ((char)b) + { + case ' ': + case '\t': + case '\f': + continue; // Skip whitespace + + case '\n': + return; + + case '\r': + if (NextByte() != '\n') + { + PutBack(); + } + + return; + + default: + throw new PdfMetadataExtractorException("Unexpected character, expected newline, got {b}"); + } + } + } + + public long GetXRefStart() + { + // Look for the startxref element as per PDF Spec 7.5.5 + while (true) + { + var b = NextByte(); + + switch ((char)b) + { + case '\r': + b = NextByte(); + + if (b != '\n') + { + PutBack(); + } + + goto case '\n'; + + case '\n': + // Handle consecutive newlines + while (true) + { + b = NextByte(); + + if (b == '\r') + { + goto case '\r'; + } + else if (b == '\n') + { + goto case '\n'; + } + else if (b == ' ' || b == '\t' || b == '\f') + { + continue; + } + else + { + PutBack(); + + break; + } + } + + var token = NextToken(true); + + if (token.Type == TokenType.Keyword && (string)token.Value == "startxref") + { + token = NextToken(); + + if (token.Type == TokenType.Int) + { + return (long)token.Value; + } + else + { + throw new PdfMetadataExtractorException("Expected integer after startxref keyword"); + } + } + + continue; + + default: + continue; + } + } + } + + public bool NextXRefEntry(ref long obj, ref int generation) + { + // Cross-reference table entry as per PDF Spec 7.5.4 + + WantLookahead(20); + + if (_valid - _pos < 20) + { + throw new PdfMetadataExtractorException("End of stream"); + } + + var inUse = true; + + if (obj == 0) + { + obj = Convert.ToInt64(Encoding.ASCII.GetString(_buffer, _pos, 10)); + generation = Convert.ToInt32(Encoding.ASCII.GetString(_buffer, _pos + 11, 5)); + inUse = _buffer[_pos + 17] == 'n'; + } + + _pos += 20; + + return inUse; + } + + public Stream StreamObject(int length, bool deflate) + { + // Read a stream object as per PDF Spec 7.3.8 + // At the moment, we only accept uncompressed streams or the FlateDecode (PDF Spec 7.4.1) filter + // with no parameters. These cover the vast majority of streams we're interested in. + + var rawData = new MemoryStream(); + + ExpectNewline(); + + if (_pos < _valid) + { + var buffered = Math.Min(_valid - _pos, length); + rawData.Write(_buffer, _pos, buffered); + length -= buffered; + _pos += buffered; + } + + while (length > 0) + { + var buffered = Math.Min(length, BufferSize); + stream.ReadExactly(_buffer, 0, buffered); + rawData.Write(_buffer, 0, buffered); + _pos = 0; + _valid = 0; + length -= buffered; + } + + rawData.Seek(0, SeekOrigin.Begin); + + if (deflate) + { + return new ZLibStream(rawData, CompressionMode.Decompress, false); + } + else + { + return rawData; + } + } + + private byte NextByte() + { + if (_pos >= _valid) + { + _pos = 0; + _valid = stream.Read(_buffer, 0, BufferSize); + + if (_valid <= 0) + { + throw new PdfMetadataExtractorException("End of stream"); + } + } + + return _buffer[_pos++]; + } + + private byte LastByte() + { + return _buffer[_pos - 1]; + } + + private void PutBack() + { + --_pos; + } + + private void ExpectByte(byte expected) + { + if (NextByte() != expected) + { + throw new PdfMetadataExtractorException($"Unexpected character, expected {expected}"); + } + } + + private void WantLookahead(int length) + { + if (_pos + length > _valid) + { + Buffer.BlockCopy(_buffer, _pos, _buffer, 0, _valid - _pos); + _valid -= _pos; + _pos = 0; + _valid += stream.Read(_buffer, _valid, BufferSize - _valid); + } + } + + private void SkipComment() + { + while (true) + { + var b = NextByte(); + + if (b == '\n') + { + break; + } + else if (b == '\r') + { + if (NextByte() != '\n') + { + PutBack(); + } + + break; + } + } + } + + private Token ScanNumber() + { + StringBuilder sb = new(); + var hasDot = LastByte() == '.'; + var followedBySpace = false; + + sb.Append((char)LastByte()); + + while (true) + { + var b = NextByte(); + + if (b == '.' || b >= '0' && b <= '9') + { + sb.Append((char)b); + + if (b == '.') + { + hasDot = true; + } + } + else + { + followedBySpace = (b == ' ' || b == '\t'); + PutBack(); + + break; + } + } + + if (hasDot) + { + return new Token(TokenType.Double, double.Parse(sb.ToString())); + } + + if (followedBySpace) + { + // Look ahead to see if it's an object reference (PDF Spec 7.3.10) + WantLookahead(32); + + var savedPos = _pos; + var b = NextByte(); + + while (b == ' ' || b == '\t') + { + b = NextByte(); + } + + // Generation number (ignored) + while (b >= '0' && b <= '9') + { + b = NextByte(); + } + + while (b == ' ' || b == '\t') + { + b = NextByte(); + } + + if (b == 'R') + { + return new Token(TokenType.ObjectRef, long.Parse(sb.ToString())); + } + else if (b == 'o' && NextByte() == 'b' && NextByte() == 'j') + { + return new Token(TokenType.ObjectStart, long.Parse(sb.ToString())); + } + else + { + _pos = savedPos; + } + } + + return new Token(TokenType.Int, long.Parse(sb.ToString())); + } + + private static int HexDigit(byte b) + { + return (char) b switch + { + >= '0' and <= '9' => b - (byte) '0', + >= 'a' and <= 'f' => b - (byte) 'a' + 10, + >= 'A' and <= 'F' => b - (byte) 'A' + 10, + _ => throw new PdfMetadataExtractorException("Invalid hex digit, got {b}") + }; + } + + private Token ScanName() + { + // PDF Spec 7.3.5 + + var sb = new StringBuilder(); + while (true) + { + var b = NextByte(); + switch ((char)b) + { + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + case '<': + case '>': + case '/': + case '%': + PutBack(); + + goto case ' '; + + case ' ': + case '\t': + case '\n': + case '\f': + case '\r': + return new Token(TokenType.Name, sb.ToString()); + + case '#': + var b1 = NextByte(); + var b2 = NextByte(); + b = (byte)((HexDigit(b1) << 4) | HexDigit(b2)); + + goto default; + + default: + sb.Append((char)b); + break; + } + } + } + + private Token ScanString() + { + // PDF Spec 7.3.4.2 + + PdfStringBuilder sb = new(); + var parenLevel = 1; + + while (true) + { + var b = NextByte(); + + switch ((char)b) + { + case '(': + parenLevel++; + + goto default; + + case ')': + if (--parenLevel == 0) + { + return new Token(TokenType.String, sb.ToString()); + } + + goto default; + + case '\\': + b = NextByte(); + + switch ((char)b) + { + case 'b': + sb.Append('\b'); + + break; + + case 'f': + sb.Append('\f'); + + break; + + case 'n': + sb.Append('\n'); + + break; + + case 'r': + sb.Append('\r'); + + break; + + case 't': + sb.Append('\t'); + + break; + + case >= '0' and <= '7': + var b1 = b; + var b2 = NextByte(); + var b3 = NextByte(); + + if (b2 < '0' || b2 > '7' || b3 < '0' || b3 > '7') + { + throw new PdfMetadataExtractorException("Invalid octal escape, got {b1}{b2}{b3}"); + } + + sb.AppendByte((byte)((b1 - '0') << 6 | (b2 - '0') << 3 | (b3 - '0'))); + + break; + } + break; + + default: + sb.AppendByte(b); + break; + } + } + } + + private Token ScanHexString() + { + // PDF Spec 7.3.4.3 + + PdfStringBuilder sb = new(); + + while (true) + { + var b = NextByte(); + + switch ((char)b) + { + case (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'): + var b1 = NextByte(); + if (b1 == '>') + { + PutBack(); + b1 = (byte)'0'; + } + sb.AppendByte((byte)(HexDigit(b) << 4 | HexDigit(b1))); + + break; + + case '>': + return new Token(TokenType.String, sb.ToString()); + + default: + throw new PdfMetadataExtractorException("Invalid hex string, got {b}"); + } + } + } + + private Token ScanKeyword() + { + StringBuilder sb = new(); + + sb.Append((char)LastByte()); + + while (true) + { + var b = NextByte(); + if ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')) + { + sb.Append((char)b); + } + else + { + PutBack(); + + break; + } + } + + switch (sb.ToString()) + { + case "true": + return new Token(TokenType.Bool, true); + + case "false": + return new Token(TokenType.Bool, false); + + case "stream": + return new Token(TokenType.StreamStart, true); + + case "endstream": + return new Token(TokenType.StreamEnd, true); + + case "endobj": + return new Token(TokenType.ObjectEnd, true); + + default: + return new Token(TokenType.Keyword, sb.ToString()); + } + } +} + +internal class PdfMetadataExtractor : IPdfMetadataExtractor +{ + private readonly ILogger _logger; + private readonly PdfLexer _lexer; + private readonly FileStream _stream; + private long[] _objectOffsets = new long[0]; + private readonly Dictionary _metadata = []; + private readonly Stack _metadataRef = new(); + + private struct MetadataRef(long root, long info) + { + public long Root = root; + public long Info = info; + } + + private struct XRefSection(long first, long count) + { + public readonly long First = first; + public readonly long Count = count; + } + + public PdfMetadataExtractor(ILogger logger, string filename) + { + _logger = logger; + _stream = File.OpenRead(filename); + _lexer = new PdfLexer(_stream); + + ReadObjectOffsets(); + ReadMetadata(filename); + } + + public Dictionary GetMetadata() + { + return _metadata; + } + + private void LogMetadata(string filename) + { + _logger.LogTrace("Metadata for {Path}:", filename); + + foreach (var entry in _metadata) + { + _logger.LogTrace(" {Key:0,-5} : {Value:1}", entry.Key, entry.Value); + } + } + + private void ReadObjectOffsets() + { + // Look for file trailer (PDF Spec 7.5.5) + // Spec says trailer must be strictly at end of file. + // Adobe software accepts trailer within last 1K of EOF, + // but in practice, virtually all PDFs have trailer at end. + + _stream.Seek(-32, SeekOrigin.End); + + var xrefOffset = _lexer.GetXRefStart(); + + ReadXRefAndTrailer(xrefOffset); + } + + private void ReadXRefAndTrailer(long xrefOffset) + { + _stream.Seek(xrefOffset, SeekOrigin.Begin); + _lexer.ResetBuffer(); + + if (!_lexer.TestByte((byte)'x')) + { + // Cross-reference stream (PDF Spec 7.5.8) + + ReadXRefStream(); + + return; + } + + // Cross-reference table (PDF Spec 7.5.4) + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Keyword || (string)token.Value != "xref") + { + throw new PdfMetadataExtractorException("Expected xref keyword"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.Int) + { + var startObj = (long)token.Value; + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected number of objects in xref subsection"); + } + + var numObj = (long)token.Value; + + if (_objectOffsets.Length < startObj + numObj) + { + Array.Resize(ref _objectOffsets, (int)(startObj + numObj)); + } + + _lexer.ExpectNewline(); + + var generation = 0; + + for (var obj = startObj; obj < startObj + numObj; ++obj) + { + var inUse = _lexer.NextXRefEntry(ref _objectOffsets[obj], ref generation); + + if (!inUse) + { + _objectOffsets[obj] = 0; + } + } + } + else if (token.Type == PdfLexer.TokenType.Keyword && (string)token.Value == "trailer") + { + break; + } + else + { + throw new PdfMetadataExtractorException("Unexpected token in xref"); + } + } + + ReadTrailerDictionary(); + } + + private void ReadXRefStream() + { + // Cross-reference stream (PDF Spec 7.5.8) + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected obj keyword"); + } + + long length = -1; + long size = -1; + var deflate = false; + long prev = -1; + long typeWidth = -1; + long offsetWidth = -1; + long generationWidth = -1; + Queue sections = new(); + var meta = new MetadataRef(-1, -1); + + // Cross-reference stream dictionary (PDF Spec 7.5.8.2) + + ParseDictionary(delegate(string key, PdfLexer.Token value) { + switch (key) + { + case "Type": + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "XRef") + { + throw new PdfMetadataExtractorException("Expected /Type to be /XRef"); + } + + return true; + + case "Length": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Length"); + } + + length = (long)value.Value; + + return true; + + case "Size": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Size"); + } + + size = (long)value.Value; + + return true; + + case "Prev": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /Prev"); + } + + prev = (long)value.Value; + + return true; + + case "Index": + if (value.Type != PdfLexer.TokenType.ArrayStart) + { + throw new PdfMetadataExtractorException("Expected array after /Index"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.ArrayEnd) + { + break; + } + else if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer in /Index array"); + } + + var first = (long)token.Value; + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer pair in /Index array"); + } + + var count = (long)token.Value; + sections.Enqueue(new XRefSection(first, count)); + } + + return true; + + case "W": + if (value.Type != PdfLexer.TokenType.ArrayStart) + { + throw new PdfMetadataExtractorException("Expected array after /W"); + } + + var widths = new long[3]; + + for (var i = 0; i < 3; ++i) + { + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer in /W array"); + } + + widths[i] = (long)token.Value; + } + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ArrayEnd) + { + throw new PdfMetadataExtractorException("Unclosed array after /W"); + } + + typeWidth = widths[0]; + offsetWidth = widths[1]; + generationWidth = widths[2]; + + return true; + + case "Filter": + if (value.Type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name after /Filter"); + } + + if ((string)value.Value != "FlateDecode") + { + throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); + } + + deflate = true; + + return true; + + case "Root": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Root"); + } + + meta.Root = (long)value.Value; + + return true; + + case "Info": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Info"); + } + + meta.Info = (long)value.Value; + + return true; + + default: + return false; + } + }); + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.StreamStart) + { + throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); + } + + var stream = _lexer.StreamObject((int)length, deflate); + + if (sections.Count == 0) + { + sections.Enqueue(new XRefSection(0, size)); + } + + while (sections.Count > 0) + { + var section = sections.Dequeue(); + + if (_objectOffsets.Length < size) + { + Array.Resize(ref _objectOffsets, (int)size); + } + + for (var i = section.First; i < section.First + section.Count; ++i) + { + long type = 0; + long offset = 0; + long generation = 0; + + if (typeWidth == 0) + { + type = 1; + } + + for (var j = 0; j < typeWidth; ++j) + { + type = (type << 8) | (ushort)stream.ReadByte(); + } + + for (var j = 0; j < offsetWidth; ++j) + { + offset = (offset << 8) | (ushort)stream.ReadByte(); + } + + for (var j = 0; j < generationWidth; ++j) + { + generation = (generation << 8) | (ushort)stream.ReadByte(); + } + + if (type == 1 && _objectOffsets[i] == 0) + { + _objectOffsets[i] = offset; + } + } + } + + if (prev > -1) + { + ReadXRefAndTrailer(prev); + } + + PushMetadataRef(meta); + } + + private void PushMetadataRef(MetadataRef meta) + { + if (_metadataRef.Count > 0) + { + if (meta.Root == _metadataRef.Peek().Root) + { + meta.Root = -1; + } + + if (meta.Info == _metadataRef.Peek().Info) + { + meta.Info = -1; + } + } + + if (meta.Root != -1 || meta.Info != -1) + { + _metadataRef.Push(meta); + } + } + + private void ReadTrailerDictionary() + { + // Read trailer directory (PDF Spec 7.5.5) + + long prev = -1; + long xrefStm = -1; + + MetadataRef meta = new(-1, -1); + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) + { + case "Root": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Root"); + } + + meta.Root = (long)value.Value; + + return true; + case "Prev": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /Prev"); + } + + prev = (long)value.Value; + + return true; + case "Info": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Info"); + } + + meta.Info = (long)value.Value; + + return true; + case "XRefStm": + // Prefer encoded xref stream over xref table + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /XRefStm"); + } + + xrefStm = (long)value.Value; + + return true; + + case "Encrypt": + throw new PdfMetadataExtractorException("Encryption not supported"); + + default: + return false; + } + }); + + PushMetadataRef(meta); + + if (xrefStm != -1) + { + ReadXRefAndTrailer(xrefStm); + } + + if (prev != -1) + { + ReadXRefAndTrailer(prev); + } + } + + private void ReadMetadata(string filename) + { + // We read potential metadata sources in backwards historical order, so + // we can overwrite to our heart's content + + while (_metadataRef.Count > 0) + { + var meta = _metadataRef.Pop(); + + //_logger.LogTrace("DocumentCatalog for {Path}: {Root}, Info: {Info}", filename, meta.root, meta.info); + + ReadMetadataFromInfo(meta.Info); + ReadMetadataFromXml(MetadataObjInObjectCatalog(meta.Root)); + } + } + + private void ReadMetadataFromInfo(long infoObj) + { + // Document information dictionary (PDF Spec 14.3.3) + // We treat this as less authoritative than the Metadata stream. + + if (infoObj < 1 || infoObj >= _objectOffsets.Length || _objectOffsets[infoObj] == 0) + { + return; + } + + _stream.Seek(_objectOffsets[infoObj], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + Dictionary indirectObjects = []; + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) + { + case "Title": + case "Author": + case "Subject": + case "Keywords": + case "Creator": + case "Producer": + case "CreationDate": + case "ModDate": + if (value.Type == PdfLexer.TokenType.ObjectRef) { + indirectObjects[key] = (long)value.Value; + } + else if (value.Type != PdfLexer.TokenType.String) + { + throw new PdfMetadataExtractorException("Expected string value"); + } + else + { + _metadata[key] = (string)value.Value; + } + + return true; + + default: + return false; + } + }); + + // Resolve indirectly referenced values + foreach(var key in indirectObjects.Keys) { + _stream.Seek(_objectOffsets[indirectObjects[key]], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) { + throw new PdfMetadataExtractorException("Expected object here"); + } + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.String) { + throw new PdfMetadataExtractorException("Expected string"); + } + + _metadata[key] = (string) token.Value; + } + } + + private long MetadataObjInObjectCatalog(long rootObj) + { + // Look for /Metadata entry in document catalog (PDF Spec 7.7.2) + + if (rootObj < 1 || rootObj >= _objectOffsets.Length || _objectOffsets[rootObj] == 0) + { + return -1; + } + + _stream.Seek(_objectOffsets[rootObj], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + long meta = -1; + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) { + case "Metadata": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object number after /Metadata"); + } + + meta = (long)value.Value; + + return true; + + default: + return false; + } + }); + + return meta; + } + + // Obtain metadata from XMP stream object + // See XMP specification: https://developer.adobe.com/xmp/docs/XMPSpecifications/ + // and Dublin Core: https://www.dublincore.org/specifications/dublin-core/ + + private static string? GetTextFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + { + return (doc.DocumentElement?.SelectSingleNode(path + "//rdf:li", ns) + ?? doc.DocumentElement?.SelectSingleNode(path, ns))?.InnerText; + } + + private static string? GetListFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + { + var nodes = doc.DocumentElement?.SelectNodes(path + "//rdf:li", ns); + + if (nodes == null) return null; + + var list = new StringBuilder(); + + foreach (XmlNode n in nodes) + { + if (list.Length > 0) + { + list.Append(','); + } + + list.Append(n.InnerText); + } + + return list.Length > 0 ? list.ToString() : null; + } + + private void SetMetadata(string key, string? value) + { + if (value == null) return; + + _metadata[key] = value; + } + + private void ReadMetadataFromXml(long meta) + { + if (meta < 1 || meta >= _objectOffsets.Length || _objectOffsets[meta] == 0) return; + + _stream.Seek(_objectOffsets[meta], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + long length = -1; + var deflate = false; + + // Metadata stream dictionary (PDF Spec 14.3.2) + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) { + case "Type": + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "Metadata") + { + throw new PdfMetadataExtractorException("Expected /Type to be /Metadata"); + } + + return true; + + case "Subtype": + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "XML") + { + throw new PdfMetadataExtractorException("Expected /Subtype to be /XML"); + } + + return true; + + case "Length": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Length"); + } + + length = (long)value.Value; + + return true; + + case "Filter": + if (value.Type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name after /Filter"); + } + + if ((string)value.Value != "FlateDecode") + { + throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); + } + + deflate = true; + + return true; + + default: + return false; + } + }); + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.StreamStart) + { + throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); + } + + var xmlStream = _lexer.StreamObject((int)length, deflate); + + // Skip XMP header + while (true) { + var b = xmlStream.ReadByte(); + + if (b < 0) { + throw new PdfMetadataExtractorException("Reached EOF in XMP header"); + } + + if (b == '?') { + while (b == '?') { + b = xmlStream.ReadByte(); + } + + if (b == '>') { + break; + } + } + } + + var metaDoc = new XmlDocument(); + metaDoc.Load(xmlStream); + + var ns = new XmlNamespaceManager(metaDoc.NameTable); + ns.AddNamespace("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"); + ns.AddNamespace("dc", "http://purl.org/dc/elements/1.1/"); + ns.AddNamespace("calibreSI", "http://calibre-ebook.com/xmp-namespace-series-index"); + ns.AddNamespace("calibre", "http://calibre-ebook.com/xmp-namespace"); + ns.AddNamespace("pdfx", "http://ns.adobe.com/pdfx/1.3/"); + ns.AddNamespace("prism", "http://prismstandard.org/namespaces/basic/2.0/"); + ns.AddNamespace("xmp", "http://ns.adobe.com/xap/1.0/"); + + SetMetadata("CreationDate", + GetTextFromXmlNode(metaDoc, ns, "//dc:date") + ?? GetTextFromXmlNode(metaDoc, ns, "//xmp:CreateDate")); + SetMetadata("Summary", GetTextFromXmlNode(metaDoc, ns, "//dc:description")); + SetMetadata("Publisher", GetTextFromXmlNode(metaDoc, ns, "//dc:publisher")); + SetMetadata("Author", GetListFromXmlNode(metaDoc, ns, "//dc:creator")); + SetMetadata("Title", GetTextFromXmlNode(metaDoc, ns, "//dc:title")); + SetMetadata("Subject", GetListFromXmlNode(metaDoc, ns, "//dc:subject")); + SetMetadata("Language", GetTextFromXmlNode(metaDoc, ns, "//dc:language")); + SetMetadata("ISBN", GetTextFromXmlNode(metaDoc, ns, "//pdfx:isbn") ?? GetTextFromXmlNode(metaDoc, ns, "//prism:isbn")); + SetMetadata("UserRating", GetTextFromXmlNode(metaDoc, ns, "//calibre:rating")); + SetMetadata("TitleSort", GetTextFromXmlNode(metaDoc, ns, "//calibre:title_sort")); + SetMetadata("Series", GetTextFromXmlNode(metaDoc, ns, "//calibre:series/rdf:value")); + SetMetadata("Volume", GetTextFromXmlNode(metaDoc, ns, "//calibreSI:series_index")); + } + + private delegate bool DictionaryHandler(string key, PdfLexer.Token value); + + private void ParseDictionary(DictionaryHandler handler) + { + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.DictionaryStart) + { + throw new PdfMetadataExtractorException("Expected dictionary"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.DictionaryEnd) + { + return; + } + + if (token.Type == PdfLexer.TokenType.Name) + { + var value = _lexer.NextToken(); + + if (!handler((string)token.Value, value)) { + SkipValue(value); + } + } + else + { + throw new PdfMetadataExtractorException("Improper token in dictionary"); + } + } + } + + private void SkipValue(PdfLexer.Token? existingToken = null) + { + var token = existingToken ?? _lexer.NextToken(); + + switch (token.Type) + { + case PdfLexer.TokenType.Bool: + case PdfLexer.TokenType.Int: + case PdfLexer.TokenType.Double: + case PdfLexer.TokenType.Name: + case PdfLexer.TokenType.String: + case PdfLexer.TokenType.ObjectRef: + break; + case PdfLexer.TokenType.ArrayStart: + { + SkipArray(); + break; + } + case PdfLexer.TokenType.DictionaryStart: + { + SkipDictionary(); + break; + } + default: + throw new PdfMetadataExtractorException("Unexpected token in SkipValue"); + } + } + + private void SkipArray() + { + while (true) + { + var token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.ArrayEnd) + { + break; + } + + SkipValue(token); + } + } + + private void SkipDictionary() + { + while (true) + { + var token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.DictionaryEnd) + { + break; + } + if (token.Type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name in dictionary"); + } + + SkipValue(); + } + } +} diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 193513453..b71ff2c1a 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -7,6 +6,7 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; using API.Helpers.Builders; @@ -17,6 +17,20 @@ namespace API.Helpers; public static class PersonHelper { + public static Dictionary ConstructNameAndAliasDictionary(IList people) + { + var dict = new Dictionary(); + foreach (var person in people) + { + dict.TryAdd(person.NormalizedName, person); + foreach (var alias in person.Aliases) + { + dict.TryAdd(alias.NormalizedAlias, person); + } + } + return dict; + } + public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection metadataPeople, IEnumerable chapterPeople, PersonRole role, IUnitOfWork unitOfWork) { @@ -38,7 +52,9 @@ public static class PersonHelper // Identify people to remove from metadataPeople var peopleToRemove = existingMetadataPeople - .Where(person => !peopleToAddSet.Contains(person.Person.NormalizedName)) + .Where(person => + !peopleToAddSet.Contains(person.Person.NormalizedName) && + !person.Person.Aliases.Any(pa => peopleToAddSet.Contains(pa.NormalizedAlias))) .ToList(); // Remove identified people from metadataPeople @@ -53,11 +69,7 @@ public static class PersonHelper .GetPeopleByNames(peopleToAdd.Select(p => p.NormalizedName).ToList()); // Prepare a dictionary for quick lookup of existing people by normalized name - var existingPeopleDict = new Dictionary(); - foreach (var person in existingPeopleInDb) - { - existingPeopleDict.TryAdd(person.NormalizedName, person); - } + var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeopleInDb); // Track the people to attach (newly created people) var peopleToAttach = new List(); @@ -80,7 +92,6 @@ public static class PersonHelper // 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; } // Add the person to the SeriesMetadataPeople collection @@ -130,15 +141,12 @@ public static class PersonHelper var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedPeople); // Prepare a dictionary for quick lookup by normalized name - var existingPeopleDict = new Dictionary(); - foreach (var person in existingPeople) - { - existingPeopleDict.TryAdd(person.NormalizedName, person); - } + var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeople); // Identify people to remove (those present in ChapterPeople but not in the new list) - foreach (var existingChapterPerson in existingChapterPeople - .Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName))) + var toRemove = existingChapterPeople + .Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName)); + foreach (var existingChapterPerson in toRemove) { chapter.People.Remove(existingChapterPerson); unitOfWork.PersonRepository.Remove(existingChapterPerson); diff --git a/API/Services/ReviewService.cs b/API/Helpers/ReviewHelper.cs similarity index 84% rename from API/Services/ReviewService.cs rename to API/Helpers/ReviewHelper.cs index c2c876b4b..03c50a4cf 100644 --- a/API/Services/ReviewService.cs +++ b/API/Helpers/ReviewHelper.cs @@ -5,10 +5,9 @@ using System.Text.RegularExpressions; using API.DTOs.SeriesDetail; using HtmlAgilityPack; +namespace API.Helpers; -namespace API.Services; - -public static class ReviewService +public static class ReviewHelper { private const int BodyTextLimit = 175; public static IEnumerable SelectSpectrumOfReviews(IList reviews) @@ -60,6 +59,9 @@ public static class ReviewService .Where(s => !s.Equals("\n"))); // Clean any leftover markdown out + plainText = Regex.Replace(plainText, @"\*\*(.*?)\*\*", "$1"); // Bold with ** + plainText = Regex.Replace(plainText, @"_(.*?)_", "$1"); // Italic with _ + plainText = Regex.Replace(plainText, @"\[(.*?)\]\((.*?)\)", "$1"); // Links [text](url) plainText = Regex.Replace(plainText, @"[_*\[\]~]", string.Empty); plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty); plainText = Regex.Replace(plainText, @"~~~(.*?)~~~", "$1"); @@ -68,6 +70,7 @@ public static class ReviewService plainText = Regex.Replace(plainText, @"__(.*?)__", "$1"); plainText = Regex.Replace(plainText, @"#\s(.*?)", "$1"); + // Just strip symbols plainText = Regex.Replace(plainText, @"[_*\[\]~]", string.Empty); plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty); @@ -76,8 +79,8 @@ public static class ReviewService plainText = Regex.Replace(plainText, @"~~", string.Empty); plainText = Regex.Replace(plainText, @"__", string.Empty); - // Take the first 100 characters - plainText = plainText.Length > 100 ? plainText.Substring(0, BodyTextLimit) : plainText; + // Take the first BodyTextLimit characters + plainText = plainText.Length > BodyTextLimit ? plainText.Substring(0, BodyTextLimit) : plainText; return plainText + "…"; } diff --git a/API/Helpers/StringHelper.cs b/API/Helpers/StringHelper.cs new file mode 100644 index 000000000..0a20910c5 --- /dev/null +++ b/API/Helpers/StringHelper.cs @@ -0,0 +1,69 @@ +using System.Text.RegularExpressions; + +namespace API.Helpers; +#nullable enable + +public static partial class StringHelper +{ + #region Regex Source Generators + [GeneratedRegex(@"\s?\(Source:\s*[^)]+\)")] + private static partial Regex SourceRegex(); + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex BrStandardizeRegex(); + [GeneratedRegex(@"(?:
\s*)+", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex BrMultipleRegex(); + [GeneratedRegex(@"\s+")] + private static partial Regex WhiteSpaceRegex(); + [GeneratedRegex("&#64;")] + private static partial Regex HtmlEncodedAtSymbolRegex(); + #endregion + + /// + /// Used to squash duplicate break and new lines with a single new line. + /// + /// Test br br Test -> Test br Test + /// + /// + public static string? SquashBreaklines(string? summary) + { + if (string.IsNullOrWhiteSpace(summary)) + { + return null; + } + + // First standardize all br tags to
format + summary = BrStandardizeRegex().Replace(summary, "
"); + + // Replace multiple consecutive br tags with a single br tag + summary = BrMultipleRegex().Replace(summary, "
"); + + // Normalize remaining whitespace (replace multiple spaces with a single space) + summary = WhiteSpaceRegex().Replace(summary, " ").Trim(); + + return summary.Trim(); + } + + /// + /// Removes the (Source: MangaDex) type of tags at the end of descriptions from AL + /// + /// + /// + public static string? RemoveSourceInDescription(string? description) + { + if (string.IsNullOrEmpty(description)) return description; + + return SourceRegex().Replace(description, string.Empty).Trim(); + } + + /// + /// Replaces some HTML encoded characters in urls with the proper symbol. This is common in People Description's + /// + /// + /// + public static string? CorrectUrls(string? description) + { + if (string.IsNullOrEmpty(description)) return description; + + return HtmlEncodedAtSymbolRegex().Replace(description, "@"); + } +} diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs index cceecc826..c00d6ee8f 100644 --- a/API/Helpers/TagHelper.cs +++ b/API/Helpers/TagHelper.cs @@ -20,7 +20,13 @@ public static class TagHelper public static async Task UpdateChapterTags(Chapter chapter, IEnumerable tagNames, IUnitOfWork unitOfWork) { // Normalize tag names once and store them in a hash set for quick lookups - var normalizedTagsToAdd = new HashSet(tagNames.Select(t => t.ToNormalized())); + // Create a dictionary: normalized => original + var normalizedToOriginal = tagNames + .Select(t => new { Original = t, Normalized = t.ToNormalized() }) + .GroupBy(x => x.Normalized) // in case of duplicates + .ToDictionary(g => g.Key, g => g.First().Original); + + var normalizedTagsToAdd = new HashSet(normalizedToOriginal.Keys); var existingTagsSet = new HashSet(chapter.Tags.Select(t => t.NormalizedTitle)); var isModified = false; @@ -30,7 +36,7 @@ public static class TagHelper .Where(t => !normalizedTagsToAdd.Contains(t.NormalizedTitle)) .ToList(); - if (tagsToRemove.Any()) + if (tagsToRemove.Count != 0) { foreach (var tagToRemove in tagsToRemove) { @@ -47,7 +53,7 @@ public static class TagHelper // Find missing tags that are not already in the database var missingTags = normalizedTagsToAdd .Where(nt => !existingTagTitles.ContainsKey(nt)) - .Select(title => new TagBuilder(title).Build()) + .Select(nt => new TagBuilder(normalizedToOriginal[nt]).Build()) .ToList(); // Add missing tags to the database if any @@ -67,13 +73,11 @@ public static class TagHelper // Add the new or existing tags to the chapter foreach (var normalizedTitle in normalizedTagsToAdd) { - var tag = existingTagTitles[normalizedTitle]; + if (existingTagsSet.Contains(normalizedTitle)) continue; - if (!existingTagsSet.Contains(normalizedTitle)) - { - chapter.Tags.Add(tag); - isModified = true; - } + var tag = existingTagTitles[normalizedTitle]; + chapter.Tags.Add(tag); + isModified = true; } // Commit changes if modifications were made to the chapter's tags @@ -103,13 +107,18 @@ public static class TagHelper public static void UpdateTagList(ICollection? existingDbTags, Series series, IReadOnlyCollection newTags, Action handleAdd, Action onModified) + { + UpdateTagList((existingDbTags ?? []).Select(t => t.Title).ToList(), series, newTags, handleAdd, onModified); + } + + public static void UpdateTagList(ICollection? existingDbTags, Series series, IReadOnlyCollection newTags, Action handleAdd, Action onModified) { if (existingDbTags == null) return; var isModified = false; // 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 existingTagSet = new HashSet(existingDbTags.Select(t => t.ToNormalized())); var dbTagSet = new HashSet(series.Metadata.Tags.Select(g => g.NormalizedTitle)); // Remove tags that are no longer present in the input tags @@ -129,7 +138,7 @@ public static class TagHelper // Add new tags from the input list foreach (var tagDto in existingDbTags) { - var normalizedTitle = tagDto.Title.ToNormalized(); + var normalizedTitle = tagDto.ToNormalized(); if (dbTagSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres @@ -139,7 +148,7 @@ public static class TagHelper } else { - handleAdd(new TagBuilder(tagDto.Title).Build()); // Add new genre if not found + handleAdd(new TagBuilder(tagDto).Build()); // Add new genre if not found } isModified = true; } @@ -150,5 +159,4 @@ public static class TagHelper onModified(); } } - } diff --git a/API/I18N/as.json b/API/I18N/as.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/API/I18N/as.json @@ -0,0 +1 @@ +{} diff --git a/API/I18N/ca.json b/API/I18N/ca.json new file mode 100644 index 000000000..b314a9374 --- /dev/null +++ b/API/I18N/ca.json @@ -0,0 +1,65 @@ +{ + "confirm-email": "Heu de confirmar l'adreça electrònica primer", + "invalid-password": "La contrasenya no és vàlida", + "nothing-to-do": "Res a fer", + "no-user": "El compte no existeix", + "invalid-token": "El testimoni no és vàlid", + "volume-num": "Volum {0}", + "book-num": "Llibre {0}", + "issue-num": "Número {0}{1}", + "chapter-num": "Capítol {0}", + "check-updates": "Comprova si hi ha actualitzacions", + "invalid-username": "El nom d'usuari no és vàlid", + "chapter-doesnt-exist": "El capítol no existeix", + "collection-updated": "S'ha actualitzat la col·lecció correctament", + "collection-deleted": "S'ha suprimit la col·lecció", + "collection-doesnt-exist": "La col·lecció no existeix", + "collection-already-exists": "La col·lecció ja existeix", + "person-doesnt-exist": "La persona no existeix", + "device-doesnt-exist": "El dispositiu no existeix", + "volume-doesnt-exist": "El volum no existeix", + "series-doesnt-exist": "La sèrie no existeix", + "no-cover-image": "No hi ha cap imatge de coberta", + "library-name-exists": "El nom de la biblioteca ja existeix. Trieu un nom únic per al servidor.", + "generic-library": "S'ha produït un error greu. Torneu-ho a provar.", + "invalid-filename": "El nom de fitxer no és vàlid", + "file-doesnt-exist": "El fitxer no existeix", + "user-doesnt-exist": "El compte no existeix", + "no-library-access": "El compte no té accés a aquesta biblioteca", + "library-doesnt-exist": "La biblioteca no existeix", + "invalid-path": "El camí no és vàlid", + "libraries": "Totes les biblioteques", + "browse-libraries": "Explora per biblioteques", + "collections": "Totes les col·leccions", + "browse-collections": "Explora per col·leccions", + "smart-filters": "Filtres intel·ligents", + "external-sources": "Fonts externes", + "browse-external-sources": "Explora les fonts externes", + "search": "Cerca", + "search-description": "Cerca sèries, col·leccions o llistes de lectura", + "external-source-required": "Cal la clau de l'API i l'amfitrió", + "smart-filter-doesnt-exist": "El filtre intel·ligent no existeix", + "collection-tag-duplicate": "Ja existeix una col·lecció amb aquest nom", + "device-duplicate": "Ja existeix un dispositiu amb aquest nom", + "send-to-permission": "No és possible enviar fitxers que no siguin EPUB o PDF perquè el Kindle no els admet", + "browse-smart-filters": "Explora per filtres intel·ligents", + "external-source-already-exists": "La font externa ja existeix", + "device-not-created": "Aquest dispositiu no existeix encara. Creeu-lo primer", + "external-source-doesnt-exist": "La font externa no existeix", + "backup": "Còpia de seguretat", + "file-missing": "No s'ha trobat el fitxer al llibre", + "reading-list-deleted": "S'ha suprimit la llista de lectura", + "generic-device-delete": "S'ha produït un error en suprimir el dispositiu", + "reading-list-position": "No s'ha pogut actualitzar la posició", + "generic-reading-list-delete": "S'ha produït un problema en suprimir la llista de lectura", + "generic-device-create": "S'ha produït un error en crear el dispositiu", + "generic-device-update": "S'ha produït un error en actualitzar el dispositiu", + "reading-list-doesnt-exist": "La llista de lectura no existeix", + "update-metadata-fail": "No s'han pogut actualitzar les metadades", + "reading-list-name-exists": "Ja existeix una llista de lectura amb aquest nom", + "ip-address-invalid": "L'adreça IP «{0}» no és vàlida", + "reading-list-item-delete": "No s'han pogut suprimir els elements", + "generic-reading-list-create": "S'ha produït un problema en crear la llista de lectura", + "generic-reading-list-update": "S'ha produït un problema en actualitzar la llista de lectura", + "generic-create-temp-archive": "S'ha produït un problema en crear l'arxiu temporal" +} diff --git a/API/I18N/cs.json b/API/I18N/cs.json index fa465b1fd..e136d8e75 100644 --- a/API/I18N/cs.json +++ b/API/I18N/cs.json @@ -187,7 +187,7 @@ "account-email-invalid": "Email v souboru účtu správce není platný email. Nelze poslat testovací email.", "email-settings-invalid": "Chybí informace o nastavení emailu. Ujistěte se, zda-li jsou uložena všechna nastavení.", "send-to-unallowed": "Nemůžete odeslat do zařízení které není vaše", - "send-to-size-limit": "Soubor(y) které se snažíte poslat jsou příliš velké pro vašeho email poskytovatele", + "send-to-size-limit": "Soubor(y) které se snažíte poslat jsou příliš velké pro vašeho poskytovatele emailu", "error-import-stack": "Při importu zásobníku MAL došlo k chybě", "collection-already-exists": "Kolekce již existuje", "generic-cover-person-save": "Nebylo možné uložit cover obrázek pro Osobu", @@ -197,5 +197,17 @@ "scan-libraries": "Skenovat Knihovny", "backup": "Záloha", "update-yearly-stats": "Aktualizovat roční statistiky", - "remove-from-want-to-read": "Vyčištění Chci si přečíst" + "remove-from-want-to-read": "Vyčištění Chci si přečíst", + "person-doesnt-exist": "Osoba neexistuje", + "person-name-required": "Jméno osoby je povinné a nesmí být prázdné", + "person-name-unique": "Jméno osoby musí být unikátní", + "person-image-doesnt-exist": "Osoba neexistuje v databázi CoversDB", + "email-taken": "Email je již používán", + "kavitaplus-restricted": "Toto je omezeno pouze na Kavita+", + "dashboard-stream-only-delete-smart-filter": "Z ovládacího panelu lze odstranit pouze streamy chytrých filtrů", + "smart-filter-name-required": "Vyžaduje se název chytrého filtru", + "smart-filter-system-name": "Nelze použít název streamu poskytovaného systémem", + "sidenav-stream-only-delete-smart-filter": "Z postranní navigace lze odstranit pouze streamy chytrých filtrů", + "aliases-have-overlap": "Jeden nebo více aliasů se překrývají s jinými osobami, nelze je aktualizovat", + "generated-reading-profile-name": "Generováno z {0}" } diff --git a/API/I18N/de.json b/API/I18N/de.json index 1e2e051c4..a6c865897 100644 --- a/API/I18N/de.json +++ b/API/I18N/de.json @@ -38,7 +38,7 @@ "generic-error": "Es ist ein Fehler ist aufgetreten, bitte versuchen Sie es erneut", "device-doesnt-exist": "Das Gerät existiert nicht", "generic-device-create": "Beim Erstellen des Geräts ist ein Fehler aufgetreten", - "send-to-kavita-email": "Das Senden an das Gerät kann nicht mit dem E-Mail-Dienst von Kavita durchgeführt werden. Bitte konfigurieren Sie Ihren eigenen.", + "send-to-kavita-email": "Senden an Gerät kann ohne E-Mail-Einrichtung nicht verwendet werden", "send-to-device-status": "Übertrage Dateien auf Ihr Gerät", "series-doesnt-exist": "Die Serie existiert nicht", "volume-doesnt-exist": "Der Band existiert nicht", @@ -178,8 +178,8 @@ "unable-to-reset-k+": "Aufgrund eines Fehlers konnte die Kavita+ Lizenz nicht zurückgesetzt werden. Kontaktieren Sie den Kavita+ Support", "email-not-enabled": "Der Mailversand ist auf diesem Server nicht aktiviert. Sie können diese Aktion nicht durchführen.", "invalid-email": "Die für den Benutzer hinterlegte E-Mail ist ungültig. Links finden Sie in den Logs.", - "send-to-unallowed": "Sie können nicht an ein fremdes Gerät senden.", - "send-to-size-limit": "Die Datei(en), die Sie senden möchten, ist/sind zu groß für Ihren E-Mail-Service", + "send-to-unallowed": "Sie können nicht an ein Gerät senden, das nicht Ihnen gehört", + "send-to-size-limit": "Die Datei(en), die Sie zu senden versuchen, sind zu groß für Ihren E-Mail-Anbieter", "check-updates": "Updates überprüfen", "email-settings-invalid": "E-Mail-Einstellungen fehlen Informationen. Stellen Sie sicher, dass alle E-Mail-Einstellungen gespeichert sind.", "account-email-invalid": "Die für das Admin-Konto gespeicherte E-Mail ist nicht gültig. Test-E-Mail kann nicht gesendet werden.", @@ -197,5 +197,17 @@ "scan-libraries": "Bibliotheken scannen", "collection-already-exists": "Sammlung existiert schon", "generic-cover-volume-save": "Coverbild kann nicht auf Volume gespeichert werden", - "generic-cover-person-save": "Das Titelbild kann nicht zu dieser Person gespeichert werden" + "generic-cover-person-save": "Das Titelbild kann nicht zu dieser Person gespeichert werden", + "person-doesnt-exist": "Die Person existiert nicht", + "person-name-required": "Personenname ist erforderlich und darf nicht null sein", + "person-name-unique": "Der Name der Person muss eindeutig sein", + "person-image-doesnt-exist": "Die Person existiert nicht in CoversDB", + "email-taken": "E-Mail bereits in Gebrauch", + "kavitaplus-restricted": "Dies ist nur auf Kavita+ beschränkt", + "sidenav-stream-only-delete-smart-filter": "Nur Smart-Filter-Streams können aus der Seitennavigation gelöscht werden", + "dashboard-stream-only-delete-smart-filter": "Nur Smart-Filter-Streams können aus dem Dashboard gelöscht werden", + "smart-filter-system-name": "Du kannst den Namen eines vom System bereitgestellten Streams nicht verwenden", + "smart-filter-name-required": "Name des Smart Filters erforderlich", + "aliases-have-overlap": "Ein oder mehrere Aliasnamen sind mit anderen Personen identisch und können nicht aktualisiert werden", + "generated-reading-profile-name": "Erstellt aus {0}" } diff --git a/API/I18N/en.json b/API/I18N/en.json index 1e8b5aa1f..d3cd1ecd3 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -18,6 +18,7 @@ "age-restriction-update": "There was an error updating the age restriction", "no-user": "User does not exist", "username-taken": "Username already taken", + "email-taken": "Email already in use", "user-already-confirmed": "User is already confirmed", "generic-user-update": "There was an exception when updating the user", "manual-setup-fail": "Manual setup is unable to be completed. Please cancel and recreate the invite", @@ -53,6 +54,9 @@ "error-import-stack": "There was an issue importing MAL stack", "person-doesnt-exist": "Person does not exist", + "person-name-required": "Person name is required and must not be null", + "person-name-unique": "Person name must be unique", + "person-image-doesnt-exist": "Person does not exist in CoversDB", "device-doesnt-exist": "Device does not exist", "generic-device-create": "There was an error when creating the device", @@ -61,7 +65,7 @@ "greater-0": "{0} must be greater than 0", "send-to-kavita-email": "Send to device cannot be used without Email setup", "send-to-unallowed":"You cannot send to a device that isn't yours", - "send-to-size-limit": "The file(s) you are trying to send are too large for your emailer", + "send-to-size-limit": "The file(s) you are trying to send are too large for your email provider", "send-to-device-status": "Transferring files to your device", "generic-send-to": "There was an error sending the file(s) to the device", "series-doesnt-exist": "Series does not exist", @@ -182,6 +186,10 @@ "external-source-required": "ApiKey and Host required", "external-source-doesnt-exist": "External Source doesn't exist", "external-source-already-in-use": "There is an existing stream with this External Source", + "sidenav-stream-only-delete-smart-filter": "Only smart filter streams can be deleted from the SideNav", + "dashboard-stream-only-delete-smart-filter": "Only smart filter streams can be deleted from the dashboard", + "smart-filter-name-required": "Smart Filter name required", + "smart-filter-system-name": "You cannot use the name of a system provided stream", "not-authenticated": "User is not authenticated", "unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support", @@ -203,6 +211,8 @@ "reading-list-name-exists": "A reading list of this name already exists", "user-no-access-library-from-series": "User does not have access to the library this series belongs to", "series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions", + "kavitaplus-restricted": "This is restricted to Kavita+ only", + "aliases-have-overlap": "One or more of the aliases have overlap with other people, cannot update", "volume-num": "Volume {0}", "book-num": "Book {0}", @@ -220,6 +230,8 @@ "scan-libraries": "Scan Libraries", "kavita+-data-refresh": "Kavita+ Data Refresh", "backup": "Backup", - "update-yearly-stats": "Update Yearly Stats" + "update-yearly-stats": "Update Yearly Stats", + + "generated-reading-profile-name": "Generated from {0}" } diff --git a/API/I18N/es.json b/API/I18N/es.json index e2aafdb42..ca1a5c38a 100644 --- a/API/I18N/es.json +++ b/API/I18N/es.json @@ -22,7 +22,7 @@ "bookmarks-empty": "Los marcadores no pueden estar vacíos", "must-be-defined": "{0} debe estar definido", "invalid-filename": "Nombre de archivo no válido", - "library-name-exists": "El nombre de la biblioteca ya existe. Por favor, elige un nombre único.", + "library-name-exists": "El nombre de la biblioteca ya existe. Elija un nombre unívoco para el servidor.", "user-doesnt-exist": "El usuario no existe", "library-doesnt-exist": "La biblioteca no existe", "age-restriction-update": "Ha ocurrido un error al actualizar la restricción de edad", @@ -38,11 +38,11 @@ "generic-device-create": "Ha ocurrido un error al crear el dispositivo", "greater-0": "{0} debe ser mayor que 0", "send-to-kavita-email": "Enviar al dispositivo no se puede utilizar sin configurar el correo electrónico", - "no-cover-image": "No hay imagen de portada", + "no-cover-image": "No hay imagen de cubierta", "bookmark-doesnt-exist": "El marcador no existe", "generic-favicon": "Ha ocurrido un error al obtener el icono para el dominio", "file-doesnt-exist": "El archivo no existe", - "generic-library": "Ha ocurrido un error fatal. Por favor, inténtalo de nuevo.", + "generic-library": "Ha ocurrido un error grave. Inténtelo de nuevo.", "no-library-access": "El usuario no tiene acceso a esta biblioteca", "no-user": "El usuario no existe", "username-taken": "El nombre de usuario ya existe", @@ -87,7 +87,7 @@ "update-metadata-fail": "No se han podido actualizar los metadatos", "generic-relationship": "Hubo un problema al actualizar las relaciones", "job-already-running": "Trabajo ya en ejecución", - "ip-address-invalid": "La dirección IP '{0}' no es válida", + "ip-address-invalid": "La dirección IP «{0}» no es válida", "bookmark-dir-permissions": "El directorio de marcadores no tiene los permisos correctos para que Kavita pueda utilizarlo", "total-backups": "El número total de copias de seguridad debe estar entre 1 y 30", "stats-permission-denied": "No está autorizado a ver las estadísticas de otro usuario", @@ -116,18 +116,18 @@ "generic-create-temp-archive": "Hubo un problema al crear un archivo temporal", "epub-malformed": "¡El archivo está malformado! No se puede leer.", "book-num": "Libro {0}", - "issue-num": "Incidencia {0}{1}", + "issue-num": "Número {0}{1}", "search-description": "Buscar series, colecciones o listas de lectura", "unable-to-register-k+": "No se ha podido registrar la licencia debido a un error. Póngase en contacto con el servicio de asistencia de Kavita", "bad-copy-files-for-download": "No se pueden copiar archivos al directorio temporal de descarga de archivos.", - "send-to-permission": "No se puede enviar archivos que no sean EPUB o PDF a dispositivos no compatibles con Kindle", + "send-to-permission": "No se pueden enviar archivos que no sean EPUB o PDF a los dispositivos porque Kindle no los admite", "progress-must-exist": "El progreso debe existir en el usuario", "epub-html-missing": "No se ha podido encontrar el HTML apropiado para esa página", "collection-tag-duplicate": "Ya existe una colección con este nombre", "device-duplicate": "Ya existe un dispositivo con este nombre", "collection-tag-title-required": "El título de la colección no puede estar vacío", "reading-list-title-required": "El título de la lista de lectura no puede estar vacío", - "device-not-created": "Este dispositivo aún no existe. Por favor, créelo primero", + "device-not-created": "Este dispositivo aún no existe. Créelo primero", "reading-list-name-exists": "Ya existe una lista de lectura con este nombre", "user-no-access-library-from-series": "El usuario no tiene acceso a la biblioteca a la que pertenece esta serie", "series-restricted-age-restriction": "El usuario no puede ver esta serie debido a restricciones de edad", @@ -169,7 +169,7 @@ "sidenav-stream-doesnt-exist": "SideNav Stream no existe", "external-source-doesnt-exist": "La fuente externa no existe", "external-sources": "Fuentes externas", - "external-source-required": "Se requiere la clave API y el host", + "external-source-required": "Se requiere la clave de API y el anfitrión", "smart-filter-already-in-use": "Existe una transmisión con este filtro inteligente", "invalid-email": "La dirección de correo electrónico del usuario no es válida. Consulte los registros para ver si hay algún enlace.", "browse-more-in-genre": "Ver más en {0}", @@ -177,9 +177,9 @@ "recently-updated": "Actualizado recientemente", "browse-recently-updated": "Examinar las últimas actualizaciones", "unable-to-reset-k+": "No se ha podido restablecer la licencia de Kavita+ debido a un error. Contacta con el soporte de Kavita", - "send-to-unallowed": "No puedes enviar a un dispositivo que no sea el tuyo", + "send-to-unallowed": "No puede enviar a un dispositivo que no sea el suyo", "email-not-enabled": "El correo electrónico no está habilitado en este servidor. No puede realizar esta acción.", - "send-to-size-limit": "El(Los) archivo(s) que intenta enviar es(son) demasiado(s) grande(s) para su programa de correo electrónico", + "send-to-size-limit": "El(Los) archivo(s) que intenta enviar es(son) demasiado(s) grande(s) para su proveedor de correo electrónico", "process-scrobbling-events": "Procesar eventos de scrobbling", "report-stats": "Informe de estadísticas", "check-scrobbling-tokens": "Comprobar los token de scrobbling", @@ -187,7 +187,7 @@ "cleanup": "Limpieza", "remove-from-want-to-read": "Eliminar de querer leer", "kavita+-data-refresh": "Actualización de los datos de Kavita+", - "backup": "Copia de seguridad", + "backup": "Copia de respaldo", "update-yearly-stats": "Actualizar estadísticas anualmente", "license-check": "Comprobar la licencia", "scan-libraries": "Escanear la biblioteca", @@ -197,5 +197,11 @@ "collection-already-exists": "La colección ya existe", "error-import-stack": "Problema al importar la pila MAL", "generic-cover-person-save": "No se puede guardar la imagen de portada para esta persona", - "generic-cover-volume-save": "No se puede guardar la imagen de portada en el volumen" + "generic-cover-volume-save": "No se puede guardar la imagen de portada en el volumen", + "person-doesnt-exist": "No existe la persona", + "person-name-required": "El nombre de la persona es obligatorio y no debe estar vacío", + "person-name-unique": "El nombre de la persona debe ser único", + "person-image-doesnt-exist": "La persona no existe en CoversDB", + "email-taken": "El correo electrónico ya está en uso", + "kavitaplus-restricted": "Esto está restringido a Kavita+" } diff --git a/API/I18N/fa.json b/API/I18N/fa.json new file mode 100644 index 000000000..0933c41d5 --- /dev/null +++ b/API/I18N/fa.json @@ -0,0 +1,7 @@ +{ + "validate-email": "در اعتبارسنجی ایمیل شما مشکلی رخ داد: {0}", + "confirm-email": "اول ایمیل خود را تایید کنید", + "locked-out": "شما به علت تلاش‌های ورود بیش از حد، محدود شدید. لطفاً ۱۰ دقیقه صبر کنید.", + "disabled-account": "حساب شما غیرفعال شده است ، لطفاً با مدیر سرور تماس حاصل کنید.", + "register-user": "در هنگام ثبت کاربر مشکلی رخ داد." +} diff --git a/API/I18N/fi.json b/API/I18N/fi.json index 8cea1b80a..dd2a3f070 100644 --- a/API/I18N/fi.json +++ b/API/I18N/fi.json @@ -10,7 +10,7 @@ "generic-device-update": "Laitteen päivittämisessä tapahtui virhe", "send-to-kavita-email": "Laitteelle lähettämistä ei voi käyttää ilman sähköpostin asetuksia", "send-to-unallowed": "Et voi lähettää laitteeseen, joka ei ole sinun", - "send-to-size-limit": "Tiedostot, joita yrität lähettää, ovat liian suuria sähköpostiisi", + "send-to-size-limit": "Tiedostot, joita yrität lähettää, ovat liian suuria sähköpostipalveluntarjoajallesi", "send-to-device-status": "Tiedostoja siirretään laitteellesi", "generic-send-to": "Tiedoston / tiedostojen lähettämisessä tapahtui virhe", "no-cover-image": "Ei kansikuvaa", @@ -42,8 +42,8 @@ "encode-as-warning": "Et voi muuttaa PNG:ksi. Kansikuville, kokeile päivitystä. Kirjamerkkejä ja faviconeja ei voi koodata takaisin.", "ip-address-invalid": "IP-osoite '{0}' on virheellinen", "bookmark-dir-permissions": "Kirjanmerkki hakemistolla ei ole oikeita käyttöoikeuksia Kavitalle", - "total-backups": "Varmuuskopioiden on oltava 1-30", - "total-logs": "Kokonaislokien on oltava 1-30", + "total-backups": "Varmuuskopioiden kokonaismäärän on oltava välillä 1–30", + "total-logs": "Lokien kokonaismäärän on oltava välillä 1–30", "stats-permission-denied": "Sinulla ei ole oikeutta katsoa toisen käyttäjän tilastoja", "url-not-valid": "Url ei palauta kelvollista kuvaa tai vaatii luvan", "url-required": "Sinun on annattava url käyttääksesi", @@ -77,7 +77,7 @@ "disabled-account": "Sinun tilisi on poistettu käytöstä. Ota yhteyttä palvelimen ylläpitäjään.", "register-user": "Jokin meni pieleen, kun käyttäjä rekisteröityi", "validate-email": "Sähköpostin vahvistamisessa oli ongelma: {0}", - "confirm-token-gen": "Vahvistus merkin tuottamisessa oli ongelma", + "confirm-token-gen": "Vahvistuspoletin luomisessa oli ongelma", "denied": "Ei sallittu", "permission-denied": "Tämä toiminto on sinulta kielletty", "invalid-password": "Virheellinen Salasana", @@ -170,5 +170,10 @@ "user-no-access-library-from-series": "Käyttäjällä ei ole pääsyä kirjastoon, johon sarja kuuluu", "chapter-num": "Luku {0}", "cleanup": "Puhdistus", - "browse-reading-lists": "Selaa Lukulistoja" + "browse-reading-lists": "Selaa Lukulistoja", + "person-doesnt-exist": "Henkilöä ei ole olemassa", + "person-name-required": "Henkilön nimi on pakollinen, eikä se saa olla tyhjä", + "person-name-unique": "Henkilön nimen on oltava yksilöllinen", + "person-image-doesnt-exist": "Henkilöä ei ole olemassa CoversDB:ssa", + "email-taken": "Sähköposti jo käytössä" } diff --git a/API/I18N/fr.json b/API/I18N/fr.json index 32f9cb376..2b9a4f81b 100644 --- a/API/I18N/fr.json +++ b/API/I18N/fr.json @@ -11,7 +11,7 @@ "invalid-password": "Mot de passe invalide", "invalid-token": "Jeton invalide", "unable-to-reset-key": "Une erreur est survenue, impossible de réinitialiser la clé", - "generate-token": "Une erreur est survenue lors de la génération du jeton de confirmation de l'émail. Voir les logs", + "generate-token": "Une erreur est survenue lors de la génération du jeton de confirmation de l'email. Voir les logs", "nothing-to-do": "Rien à faire", "share-multiple-emails": "Vous ne pouvez pas partager un email sur plusieurs comptes", "age-restriction-update": "Une erreur est survenue lors de la mise à jour de la restriction d'âge", @@ -20,7 +20,7 @@ "user-already-confirmed": "L'utilisateur a déjà été confirmé", "generic-user-update": "Une erreur est survenue lors de la mise à jour de l'utilisateur", "user-already-registered": "L'utilisateur a déjà été enregistré en tant que {0}", - "user-already-invited": "L'utilisateur a déjà été invité avec cet émail et n'a pas encore accepté l'invitation.", + "user-already-invited": "L'utilisateur a déjà été invité avec cet email et n'a pas encore accepté l'invitation.", "generic-invite-user": "Une erreur est survenue lors de l'invitation de l'usager. Voir le journal.", "invalid-email-confirmation": "La confirmation de courriel est invalide", "invalid-payload": "Payload invalide", @@ -40,7 +40,7 @@ "chapter-doesnt-exist": "Chapitre non existant", "file-missing": "Fichier introuvable dans le livre", "generic-device-delete": "Erreur lors de la suppression de l'appareil", - "send-to-kavita-email": "Envoyer à l'appareil ne peut pas être utilisé sans configurer E-mail", + "send-to-kavita-email": "La fonction \"Envoyer à l'appareil\" ne peut pas être utilisée sans configurer l'email", "generic-favicon": "Erreur lors de la récupération de la favicon pour le domaine", "generic-library": "Erreur critique. Essayez à nouveau.", "delete-library-while-scan": "Vous ne pouvez pas supprimer une bibliothèque lorsqu'une analyse est en cours. Veuillez attendre la fin de l'analyse ou redémarrez Kavita, puis essayez de la supprimer", @@ -169,7 +169,7 @@ "external-source-already-exists": "La source externe existe déjà", "external-source-doesnt-exist": "La source externe n'existe pas", "external-source-required": "La clé API et l'hôte sont requis", - "invalid-email": "Le mail du fichier de l'utilisateur n'est pas un mail valide. Voir les logs pour les liens.", + "invalid-email": "L'email du fichier de l'utilisateur n'est pas un email valide. Voir les logs pour les liens.", "sidenav-stream-doesnt-exist": "Le flux de la barre de navigation latérale n'existe pas", "smart-filter-already-in-use": "Il existe un flux avec ce filtre intelligent", "browse-more-in-genre": "Parcourir plus dans {0}", @@ -179,7 +179,7 @@ "unable-to-reset-k+": "Impossible de réinitialiser la licence Kavita+ en raison d'une erreur. Contactez le support Kavita+", "email-not-enabled": "E-mail non activé sur ce serveur. Vous ne pouvez pas lancer cette action.", "send-to-unallowed": "Vous ne pouvez envoyer à un appareil qui ne vous appartient pas", - "send-to-size-limit": "Le(s) fichier(s) que vous essayez d'envoyer sont trop lourds pour votre emailer", + "send-to-size-limit": "Le(s) fichier(s) que vous essayez d'envoyer est (sont) trop volumineux pour votre fournisseur d'email", "check-updates": "Vérifier les mises à jour", "license-check": "Vérification de la licence", "cleanup": "Nettoyage", @@ -193,9 +193,21 @@ "process-processed-scrobbling-events": "Traiter les événements de Scrobbling traités", "update-yearly-stats": "Mettre à jour les statistiques annuelles", "account-email-invalid": "L'adresse électronique figurant dans le fichier du compte administrateur n'est pas valide. Impossible d'envoyer un courriel de test.", - "email-settings-invalid": "Informations manquantes dans les paramètres de l'e-mail. Assurez-vous que tous les paramètres de l'email sont sauvegardés.", + "email-settings-invalid": "Informations manquantes dans les paramètres de l'email. Assurez-vous que tous les paramètres de l'email sont sauvegardés.", "collection-already-exists": "Collection déjà existante", "error-import-stack": "Il y a eu un problème lors de l'importation de la pile MAL", "generic-cover-person-save": "Impossible d'enregistrer l'image de couverture pour cette personne", - "generic-cover-volume-save": "Impossible d'enregistrer l'image de couverture sur le volume" + "generic-cover-volume-save": "Impossible d'enregistrer l'image de couverture sur le volume", + "person-name-unique": "Le nom de la personne doit être unique", + "person-image-doesnt-exist": "La personne n'existe pas dans CoversDB", + "person-doesnt-exist": "La personne n'existe pas", + "person-name-required": "Le nom de la personne est obligatoire et ne doit pas être nul", + "email-taken": "Email déjà existant", + "kavitaplus-restricted": "Ce service est réservé à Kavita+", + "sidenav-stream-only-delete-smart-filter": "Seuls les flux de filtres intelligents peuvent être supprimés de la SideNav", + "dashboard-stream-only-delete-smart-filter": "Seuls les flux de filtres intelligents peuvent être supprimés du tableau de bord", + "smart-filter-name-required": "Nom du filtre intelligent requis", + "smart-filter-system-name": "Vous ne pouvez pas utiliser le nom d'un flux fourni par le système", + "aliases-have-overlap": "Un ou plusieurs alias se chevauchent avec d'autres personnes et ne peuvent pas être mis à jour", + "generated-reading-profile-name": "Généré à partir de {0}" } diff --git a/API/I18N/ga.json b/API/I18N/ga.json index a9aa75c1a..142425aec 100644 --- a/API/I18N/ga.json +++ b/API/I18N/ga.json @@ -43,7 +43,7 @@ "generic-device-update": "Tharla earráid agus an gléas á nuashonrú", "send-to-kavita-email": "Ní féidir seoladh chuig an ngléas a úsáid gan R-phost a shocrú", "send-to-unallowed": "Ní féidir leat seoladh chuig gléas nach leatsa é", - "send-to-size-limit": "Tá na comhaid atá tú ag iarraidh a sheoladh rómhór do do sheoltóir ríomhphoist", + "send-to-size-limit": "Tá an comhad/na comhaid atá tú ag iarraidh a sheoladh rómhór do do sholáthraí ríomhphoist", "send-to-device-status": "Comhaid a aistriú chuig do ghléas", "must-be-defined": "Ní mór {0} a shainiú", "generic-favicon": "Bhí fadhb ann maidir le favicon a fháil don fhearann", @@ -197,5 +197,17 @@ "unable-to-reset-k+": "Ní féidir ceadúnas Kavita+ a athshocrú de bharr earráide. Déan teagmháil le Tacaíocht Kavita+", "series-restricted-age-restriction": "Níl cead ag an úsáideoir an tsraith seo a fheiceáil mar gheall ar shrianta aoise", "bad-copy-files-for-download": "Ní féidir na comhaid a chóipeáil go dtí an chartlann eolaire sealadach íoslódáil.", - "epub-malformed": "Tá an comhad míchumtha! Ní féidir a léamh." + "epub-malformed": "Tá an comhad míchumtha! Ní féidir a léamh.", + "person-doesnt-exist": "Níl duine ann", + "person-name-required": "Tá ainm an duine ag teastáil agus ní féidir é a bheith ar neamhní", + "person-name-unique": "Caithfidh ainm duine a bheith uathúil", + "person-image-doesnt-exist": "Níl an duine in CoversDB", + "email-taken": "Ríomhphost in úsáid cheana féin", + "kavitaplus-restricted": "Tá sé seo teoranta do Kavita+ amháin", + "smart-filter-system-name": "Ní féidir leat ainm srutha an chórais a sholáthair tú a úsáid", + "sidenav-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh as an SideNav", + "dashboard-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh ón deais", + "smart-filter-name-required": "Ainm Scagaire Cliste ag teastáil", + "aliases-have-overlap": "Tá forluí idir ceann amháin nó níos mó de na leasainmneacha agus daoine eile, ní féidir iad a nuashonrú", + "generated-reading-profile-name": "Gineadh ó {0}" } diff --git a/API/I18N/he.json b/API/I18N/he.json index 41a9a7de7..3b2386bf6 100644 --- a/API/I18N/he.json +++ b/API/I18N/he.json @@ -21,5 +21,6 @@ "age-restriction-update": "אירעה תקלה בעת עדכון הגבלת גיל", "generic-user-update": "אירעה תקלה בעת עדכון משתמש", "user-already-registered": "משתמש רשום כבר בתור {0}", - "manual-setup-fail": "לא מתאפשר להשלים הגדרה ידנית. יש לבטל וליצור מחדש את ההזמנה" + "manual-setup-fail": "לא מתאפשר להשלים הגדרה ידנית. יש לבטל וליצור מחדש את ההזמנה", + "email-taken": "דואר אלקטרוני כבר בשימוש" } diff --git a/API/I18N/hu.json b/API/I18N/hu.json index 7c9473116..21649e2ce 100644 --- a/API/I18N/hu.json +++ b/API/I18N/hu.json @@ -196,5 +196,9 @@ "check-scrobbling-tokens": "Ellenőrizd a Feldolgozó tokeneket", "process-scrobbling-events": "Feldolgozó események feldolgozása", "process-processed-scrobbling-events": "A feldolgozott Feldolgozó események felolgozása", - "generic-cover-volume-save": "Nem lehet borítóképet menteni a kötethez" + "generic-cover-volume-save": "Nem lehet borítóképet menteni a kötethez", + "person-doesnt-exist": "A személy nem létezik", + "person-name-required": "A személy neve kötelező, és nem lehet üres", + "email-taken": "Az e-mail már használatban van", + "person-name-unique": "A személy nevének egyedinek kell lennie" } diff --git a/API/I18N/it.json b/API/I18N/it.json index a5f8cbf31..cf43101a6 100644 --- a/API/I18N/it.json +++ b/API/I18N/it.json @@ -90,7 +90,7 @@ "user-no-access-library-from-series": "L'utente non ha accesso alla libreria a cui appartiene questa serie", "volume-num": "Volume {0}", "book-num": "Libro {0}", - "issue-num": "Problema {0}{1}", + "issue-num": "Numero {0}{1}", "chapter-num": "Capitolo {0}", "epub-malformed": "Il file è corrotto! Non posso leggere.", "collection-updated": "Collezione aggiornata con successo", @@ -178,7 +178,7 @@ "browse-recently-updated": "Sfoglia gli aggiornamenti recenti", "email-not-enabled": "L'email non è attivata in questo server. Non puoi compiere questa azione.", "send-to-unallowed": "Non puoi inviare ad un dispositivo non tuo", - "send-to-size-limit": "Il/I file che stai cercando di mandare sono troppo grandi per l'email", + "send-to-size-limit": "Il/I file che stai cercando di mandare sono troppo grandi per il tuo provider email", "unable-to-reset-k+": "Impossibile ripristinare la licenza Kavita+. Contatta il supporto Kavita+", "check-updates": "Controlla aggiornamenti", "license-check": "Controlla Licenza", @@ -193,5 +193,15 @@ "kavita+-data-refresh": "Aggiornamento dati Kavita+", "backup": "Backup", "account-email-invalid": "L'e-mail in archivio per l'account amministratore non è un'e-mail valida. Impossibile inviare e-mail di prova.", - "email-settings-invalid": "Informazioni mancanti nelle impoastazioni email. Assicurati che tutte le impostazioni email siano salvate." + "email-settings-invalid": "Informazioni mancanti nelle impoastazioni email. Assicurati che tutte le impostazioni email siano salvate.", + "person-doesnt-exist": "La persona non esiste", + "email-taken": "Email già in uso", + "person-name-required": "Il nome della persona è richiesto e non può essere null", + "person-name-unique": "Il nome della persona deve essere univoco", + "person-image-doesnt-exist": "La persona non esiste su CoversDB", + "collection-already-exists": "La collezione esiste già", + "generic-cover-volume-save": "Impossibile salvare l'immagine di copertina nel Volume", + "generic-cover-person-save": "Impossibile salvare l'immagine di copertina nella Persona", + "error-import-stack": "Si è verificato un errore durante l'importazione dello stack MAL", + "kavitaplus-restricted": "Riservato a Kavita+" } diff --git a/API/I18N/ja.json b/API/I18N/ja.json index ba6b6e8e7..07efb40ef 100644 --- a/API/I18N/ja.json +++ b/API/I18N/ja.json @@ -95,7 +95,7 @@ "bookmark-permission": "ブックマーク/ブックマークを解除する権限があなたにはありません。", "valid-number": "有効なページ番号である必要があります", "duplicate-bookmark": "重複したブックマークエントリーがすでに存在します", - "send-to-size-limit": "送信しようとしているファイルはメーラーにとっては大きすぎます。", + "send-to-size-limit": "送信しようとしているファイルはあなたのメールプロバイダにとっては大きすぎます。", "series-doesnt-exist": "シリーズが存在しません", "pdf-doesnt-exist": "PDFが存在すべきですが存在しません。", "generic-reading-list-create": "リーディングリストを作成している際に問題が発生しました", @@ -180,9 +180,16 @@ "generic-create-temp-archive": "一時アーカイブの作成中に問題が発生しました", "user-no-access-library-from-series": "ユーザーは、このシリーズが所属するライブラリにアクセス権限がありません", "collection-tag-duplicate": "この名前のコレクションは既に存在しています", - "account-email-invalid": "管理者アカウントに登録されている電子メールは有効な電子メールではありません。 テストメールを送信できませんでした。", + "account-email-invalid": "管理者アカウントに登録されている電子メールは有効な電子メールではありません。 テストメールを送信できません。", "check-updates": "アップデートをチェックする", "license-check": "ライセンスを確認", "collection-already-exists": "コレクションは既に存在しています", - "email-settings-invalid": "メール設定に不足している情報があります。すべてのメール設定が保存されていることを確認してください。" + "email-settings-invalid": "メール設定に不足している情報があります。すべてのメール設定が保存されていることを確認してください。", + "email-taken": "メールアドレスは既に使われています", + "person-doesnt-exist": "人物は存在しません", + "person-name-unique": "人名は一意でなければなりません", + "person-name-required": "人物の名前は必須であり、空にすることはできません", + "person-image-doesnt-exist": "人物はCoversDBに存在しません", + "generic-cover-person-save": "カバー画像を人物に保存できません", + "generic-cover-volume-save": "カバー画像を巻に保存できません" } diff --git a/API/I18N/ko.json b/API/I18N/ko.json index fdc2a4363..7fec9f60a 100644 --- a/API/I18N/ko.json +++ b/API/I18N/ko.json @@ -177,7 +177,7 @@ "recently-updated": "최근 업데이트됨", "browse-recently-updated": "최근 업데이트된 항목 탐색", "email-not-enabled": "이 서버에서는 이메일이 활성화되지 않았습니다. 이 작업을 수행할 수 없습니다.", - "send-to-size-limit": "보내려는 파일이 이메일로 전송하기에 너무 큽니다", + "send-to-size-limit": "보내려는 파일이 이메일 제공업체에 비해 너무 큽니다", "send-to-unallowed": "본인 소유가 아닌 장치로 보낼 수 없습니다", "unable-to-reset-k+": "오류로 인해 Kavita+ 라이센스를 재설정할 수 없습니다. Kavita+ 지원팀에 연락하세요", "check-updates": "업데이트 확인", @@ -197,5 +197,17 @@ "collection-already-exists": "컬렉션이 이미 존재합니다", "error-import-stack": "MAL 스택 가져오기 중 문제가 발생했습니다", "generic-cover-person-save": "인물에 커버 이미지를 저장할 수 없습니다", - "generic-cover-volume-save": "볼륨에 커버 이미지를 저장할 수 없습니다" + "generic-cover-volume-save": "볼륨에 커버 이미지를 저장할 수 없습니다", + "person-doesnt-exist": "사람이 존재하지 않습니다", + "person-name-required": "개인 이름은 필수 항목이며 null일 수 없습니다", + "person-name-unique": "개인 이름은 고유해야 합니다", + "person-image-doesnt-exist": "CoversDB에 사람이 존재하지 않습니다", + "kavitaplus-restricted": "Kavita+만 해당", + "email-taken": "이미 사용중인 이메일", + "dashboard-stream-only-delete-smart-filter": "대시보드에서 스마트 필터 스트림만 삭제할 수 있습니다", + "sidenav-stream-only-delete-smart-filter": "사이드 메뉴에서 스마트 필터 스트림만 삭제할 수 있습니다", + "smart-filter-name-required": "스마트 필터 이름이 필요합니다", + "smart-filter-system-name": "시스템 제공 스트림 이름은 사용할 수 없습니다", + "aliases-have-overlap": "하나 이상의 별명이 다른 사용자와 중복되어 업데이트할 수 없습니다", + "generated-reading-profile-name": "{0}(으)로부터 생성됨" } diff --git a/API/I18N/pl.json b/API/I18N/pl.json index be465bf97..5372fddc0 100644 --- a/API/I18N/pl.json +++ b/API/I18N/pl.json @@ -57,7 +57,7 @@ "series-doesnt-exist": "Seria nie istnieje", "must-be-defined": "{0} musi być zdefiniowane", "email-not-enabled": "E-mail nie jest włączony na tym serwerze. Nie można wykonać tej akcji.", - "send-to-size-limit": "Plik(i), które próbujesz wysłać, są zbyt duże dla Twojego programu pocztowego", + "send-to-size-limit": "Plik(i), które próbujesz wysłać, są zbyt duże dla twojego dostawcy poczty e-mail", "generic-favicon": "Wystąpił problem z pobieraniem favicon dla domeny", "invalid-filename": "Nieprawidłowa nazwa pliku", "file-doesnt-exist": "Plik nie istnieje", @@ -158,20 +158,20 @@ "email-settings-invalid": "Brak informacji o ustawieniach poczty e-mail. Upewnij się, że wszystkie ustawienia poczty e-mail zostały zapisane.", "series-restricted": "Użytkownik nie ma dostępu do tej serii", "generic-cover-reading-list-save": "Nie można zapisać obrazu okładki na liście czytelniczej", - "browse-on-deck": "Przeglądaj półkę", + "browse-on-deck": "Przeglądaj Czytaj dalej", "reading-list-name-exists": "Lista czytelnicza o tej nazwie już istnieje", "name-required": "Nazwa nie może być pusta", "valid-number": "Wprowadź poprawny numer strony", - "duplicate-bookmark": "Zduplikowany wpis zakładki już istnieje", + "duplicate-bookmark": "Duplikat zakładki już istnieje", "reading-list-position": "Nie można zaktualizować położenia", "reading-list-deleted": "Lista czytelnicza została usunięta", "generic-reading-list-delete": "Wystąpił problem z usunięciem listy czytelniczej", - "url-required": "Musisz podać adres url, aby użyć", - "on-deck": "Na półce", + "url-required": "Musisz podać adres URL, aby użyć", + "on-deck": "Czytaj dalej", "reading-list-doesnt-exist": "Lista czytelnicza nie istnieje", "reading-list-updated": "Zaktualizowano", "generic-reading-list-create": "Wystąpił problem z utworzeniem listy czytelniczej", - "generic-scrobble-hold": "Wystąpił błąd podczas dodawania zawieszenia", + "generic-scrobble-hold": "Wystąpił błąd podczas oznaczania jako wstrzymane", "reset-chapter-lock": "Nie można zresetować blokady okładki dla rozdziału", "reading-lists": "Listy czytelnicze", "browse-reading-lists": "Przeglądaj według list czytelniczych", @@ -185,11 +185,11 @@ "collection-already-exists": "Kolekcja już istnieje", "generic-cover-person-save": "Nie udało się zapisać obrazu dla Osoby", "generic-cover-volume-save": "Nie udało się zapisać obrazu okładki dla Tomu", - "external-source-already-in-use": "Istnieje strumień z tym zewnętrznym źródłem", + "external-source-already-in-use": "Istnieje już strumień z tym zewnętrznym źródłem", "scrobble-bad-payload": "Nieprawidłowy payloadod dostawcy Scrobblowania", "process-scrobbling-events": "Zdarzenia związane z procesem Scrobblowania", "process-processed-scrobbling-events": "Przetworzone zdarzenia Scrobblowania", - "error-import-stack": "Wystąpił problem z importowaniem stosu MAL", + "error-import-stack": "Wystąpił problem z importowaniem MAL Stack", "account-email-invalid": "Adres e-mail dla konta administratora nie jest prawidłowy. Nie można wysłać testowej wiadomości e-mail.", "query-required": "Należy przekazać parametr zapytania", "smart-filter-already-in-use": "Istnieje strumień z tym inteligentnym filtrem", @@ -197,5 +197,17 @@ "sidenav-stream-doesnt-exist": "Strumień SideNav nie istnieje", "remove-from-want-to-read": "Wyczyść Chcesz przeczytać", "check-scrobbling-tokens": "Sprawdź tokeny Scrobblowania", - "invalid-email": "Adres e-mail użytkownika w pliku nie jest prawidłowy. Zobacz logi dla wszystkich linków." + "invalid-email": "Adres e-mail użytkownika w pliku nie jest prawidłowy. Zobacz logi dla wszystkich linków.", + "person-doesnt-exist": "Osoba nie istnieje", + "person-name-required": "Nazwa osoby jest wymagana i nie może mieć wartości null", + "person-name-unique": "Nazwa osoby musi być unikatowa", + "person-image-doesnt-exist": "Osoba nie istnieje w CoversDB", + "email-taken": "Adres e-mail jest już używany", + "kavitaplus-restricted": "Jest to dostępne tylko dla Kavita+", + "smart-filter-name-required": "Inteligentny filtr wymaga nazwy", + "sidenav-stream-only-delete-smart-filter": "Tylko inteligentne filtry mogą zostać usunięte z panelu bocznego", + "dashboard-stream-only-delete-smart-filter": "Tylko inteligentne strumienie filtrów może zostać usunięte z głównego panelu", + "smart-filter-system-name": "Nie można użyć nazwy systemu dostarczanego strumieniem", + "aliases-have-overlap": "Jeden lub więcej aliasów pokrywa się z innymi osobami, nie można ich zaktualizować", + "generated-reading-profile-name": "Wygenerowano z {0}" } diff --git a/API/I18N/pt.json b/API/I18N/pt.json index e98c45a6c..726c843bb 100644 --- a/API/I18N/pt.json +++ b/API/I18N/pt.json @@ -179,7 +179,7 @@ "unable-to-reset-k+": "Não foi possível redefinir a licença do Kavita+ devido a um erro. Entre em contacto com o suporte Kavita +", "email-not-enabled": "O email não está habilitado neste servidor. Não pode executar esta ação.", "send-to-unallowed": "Não pode enviar para um dispositivo que não é seu", - "send-to-size-limit": "Os ficheiros que está a tentar enviar são demasiado grandes para o seu remetente", + "send-to-size-limit": "Os ficheiros que está a tentar enviar são demasiado grandes para o seu serviço de email", "backup": "Backup", "check-scrobbling-tokens": "Verificar Tokens de Scrobbling", "cleanup": "Limpar", @@ -197,5 +197,17 @@ "collection-already-exists": "Coleção já existente", "error-import-stack": "Ocorreu um problema a importar uma pilha do MAL", "generic-cover-person-save": "Não foi possível gravar a imagem de capa na Pessoa", - "generic-cover-volume-save": "Não foi possível gravar a imagem de capa no Volume" + "generic-cover-volume-save": "Não foi possível gravar a imagem de capa no Volume", + "person-doesnt-exist": "Pessoa não existe", + "person-name-required": "O nome da pessoa é obrigatório e não pode nulo", + "person-name-unique": "O nome da pessoa tem de ser único", + "person-image-doesnt-exist": "A pessoa não existe na CoversDB", + "email-taken": "Email já em uso", + "kavitaplus-restricted": "Ação restrita ao Kavita+", + "sidenav-stream-only-delete-smart-filter": "Apenas os filtros inteligentes podem ser removidos da Navegação Lateral", + "dashboard-stream-only-delete-smart-filter": "Apenas os filtros inteligentes podem ser removidos do painel", + "smart-filter-system-name": "Não pode usar o nome de um fluxo disponibilizado pelo sistema", + "smart-filter-name-required": "Nome requerido para o filtro inteligente", + "aliases-have-overlap": "Um ou mais pseudónimos sobrepõem-se com outras pessoas, não vai ser possível atualizar", + "generated-reading-profile-name": "Gerado de {0}" } diff --git a/API/I18N/pt_BR.json b/API/I18N/pt_BR.json index e0568432c..418e0ea3b 100644 --- a/API/I18N/pt_BR.json +++ b/API/I18N/pt_BR.json @@ -179,7 +179,7 @@ "unable-to-reset-k+": "Não foi possível redefinir a licença Kavita+ devido a um erro. Entre em contato com o suporte Kavita +", "send-to-unallowed": "Você não pode enviar para um dispositivo que não seja seu", "email-not-enabled": "O e-mail não está ativado neste servidor. Você não pode executar esta ação.", - "send-to-size-limit": "Os arquivos que você está tentando enviar são muito grandes para o seu e-mail", + "send-to-size-limit": "Os arquivos que você está tentando enviar são muito grandes para o seu provedor de e-mail", "check-updates": "Verificar por Atualizações", "license-check": "Verificar Licença", "process-scrobbling-events": "Eventos de Scrobbling de Processo", @@ -197,5 +197,17 @@ "error-import-stack": "Ocorreu um problema ao importar a pilha MAL", "collection-already-exists": "A coleção já existe", "generic-cover-person-save": "Não foi possível salvar a imagem da capa em Pessoa", - "generic-cover-volume-save": "Não foi possível salvar a imagem da capa no Volume" + "generic-cover-volume-save": "Não foi possível salvar a imagem da capa no Volume", + "person-doesnt-exist": "Pessoa não existe", + "person-image-doesnt-exist": "A pessoa não existe no CoversDB", + "person-name-required": "O nome da pessoa é obrigatório e não deve ser nulo", + "person-name-unique": "O nome da pessoa deve ser exclusivo", + "email-taken": "E-mail já em uso", + "kavitaplus-restricted": "Isso é restrito apenas ao Kavita+", + "smart-filter-name-required": "Nome do Filtro Inteligente obrigatório", + "dashboard-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do painel", + "smart-filter-system-name": "Você não pode usar o nome de um fluxo fornecido pelo sistema", + "sidenav-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do Navegador Lateral", + "aliases-have-overlap": "Um ou mais dos pseudônimos se sobrepõem a outras pessoas, não pode atualizar", + "generated-reading-profile-name": "Gerado a partir de {0}" } diff --git a/API/I18N/ru.json b/API/I18N/ru.json index c75f58fb1..fdea5920f 100644 --- a/API/I18N/ru.json +++ b/API/I18N/ru.json @@ -1,5 +1,5 @@ { - "confirm-email": "Вы обязаны сначала подтвердить свою почту", + "confirm-email": "Сначала Вы обязаны подтвердить свою электронную почту", "generate-token": "Возникла проблема с генерацией токена подтверждения электронной почты. Смотрите журналы", "invalid-password": "Неверный пароль", "invalid-email-confirmation": "Неверное подтверждение электронной почты", @@ -35,15 +35,15 @@ "no-user": "Пользователь не существует", "generic-invite-user": "Возникла проблема с приглашением пользователя. Пожалуйста, проверьте журналы.", "permission-denied": "Вам запрещено выполнять эту операцию", - "invalid-access": "Недопустимый доступ", + "invalid-access": "В доступе отказано", "reading-list-name-exists": "Такой список для чтения уже существует", "perform-scan": "Пожалуйста, выполните сканирование этой серии или библиотеки и повторите попытку", "generic-device-create": "При создании устройства возникла ошибка", "generic-read-progress": "Возникла проблема с сохранением прогресса", "file-doesnt-exist": "Файл не существует", "admin-already-exists": "Администратор уже существует", - "send-to-kavita-email": "Отправка на устройство не может быть использована с почтовым сервисом Kavita. Пожалуйста, настройте свой собственный.", - "no-image-for-page": "Нет такого изображения для страницы {0}. Попробуйте обновить, чтобы обновить кэш.", + "send-to-kavita-email": "Отправка на устройство не может быть использована без настройки электронной почты", + "no-image-for-page": "Нет такого изображения для страницы {0}. Попробуйте обновить, для повторного кеширования.", "reading-list-permission": "У вас нет прав на этот список чтения или список не существует", "volume-doesnt-exist": "Том не существует", "generic-library": "Возникла критическая проблема. Пожалуйста, попробуйте еще раз.", @@ -57,7 +57,7 @@ "generic-reading-list-create": "Возникла проблема с созданием списка для чтения", "no-cover-image": "Изображение на обложке отсутствует", "collection-updated": "Коллекция успешно обновлена", - "critical-email-migration": "Возникла проблема при переносе электронной почты. Обратитесь в службу поддержки", + "critical-email-migration": "Возникла проблема при смене электронной почты. Обратитесь в службу поддержки", "cache-file-find": "Не удалось найти изображение в кэше. Перезагрузитесь и попробуйте снова.", "duplicate-bookmark": "Дублирующая закладка уже существует", "collection-tag-duplicate": "Такая коллекция уже существует", @@ -72,7 +72,7 @@ "pdf-doesnt-exist": "PDF не существует, когда он должен существовать", "generic-device-delete": "При удалении устройства возникла ошибка", "bookmarks-empty": "Закладки не могут быть пустыми", - "valid-number": "Должен быть действительный номер страницы", + "valid-number": "Номер страницы должен быть действительным", "series-doesnt-exist": "Серия не существует", "no-library-access": "Пользователь не имеет доступа к этой библиотеке", "reading-list-item-delete": "Не удалось удалить элемент(ы)", @@ -121,7 +121,7 @@ "opds-disabled": "OPDS не включен на этом сервере", "stats-permission-denied": "Вы не имеете права просматривать статистику другого пользователя", "reading-list-restricted": "Список чтения не существует или у вас нет доступа", - "favicon-doesnt-exist": "Фавикон не существует", + "favicon-doesnt-exist": "Favicon не существует", "external-source-already-in-use": "Существует поток с этим внешним источником", "issue-num": "Вопрос {0}{1}", "generic-create-temp-archive": "Возникла проблема с созданием временного архива", @@ -155,7 +155,7 @@ "generic-user-delete": "Не удалось удалить пользователя", "generic-cover-reading-list-save": "Невозможно сохранить изображение обложки в списке для чтения", "unable-to-register-k+": "Невозможно зарегистрировать лицензию из-за ошибки. Обратитесь в службу поддержки Кавита+", - "encode-as-warning": "Конвертировать в PNG невозможно. Для обложек используйте Обновить Обложки. Закладки и фавиконки нельзя закодировать обратно.", + "encode-as-warning": "Вы не можете конвертировать в формат PNG. Для обновления обложек используйте команду \"Обновить обложку\". Закладки и значки не могут быть закодированы обратно.", "want-to-read": "Хотите прочитать", "generic-user-pref": "Возникла проблема с сохранением предпочтений", "external-sources": "Внешние источники", @@ -194,5 +194,13 @@ "backup": "Резервное копирование", "process-processed-scrobbling-events": "Обработка обработанных событий скроблинга", "scan-libraries": "Сканирование библиотек", - "kavita+-data-refresh": "Обновление данных Kavita+" + "kavita+-data-refresh": "Обновление данных Kavita+", + "kavitaplus-restricted": "Это доступно только для Kavita+", + "person-doesnt-exist": "Персона не существует", + "generic-cover-volume-save": "Не удается сохранить обложку для тома", + "generic-cover-person-save": "Не удается сохранить изображение обложки для Персоны", + "person-name-unique": "Имя персоны должно быть уникальным", + "person-image-doesnt-exist": "Персона не существует в CoversDB", + "email-taken": "Почта уже используется", + "person-name-required": "Имя персоны обязательно и не может быть пустым" } diff --git a/API/I18N/sk.json b/API/I18N/sk.json index 0967ef424..ef267ed02 100644 --- a/API/I18N/sk.json +++ b/API/I18N/sk.json @@ -1 +1,213 @@ -{} +{ + "disabled-account": "Váš účet je deaktivovaný. Kontaktujte správcu servera.", + "register-user": "Niečo sa pokazilo pri registrácii užívateľa", + "confirm-email": "Najprv musíte potvrdiť svoj e-mail", + "locked-out": "Boli ste zamknutí z dôvodu veľkého počtu neúspešných pokusov o prihlásenie. Počkajte 10 minút.", + "validate-email": "Pri validácii vášho e-mailu sa vyskytla chyba: {0}", + "confirm-token-gen": "Pri vytváraní potvrdzovacieho tokenu sa vyskytla chyba", + "permission-denied": "Na vykonanie tejto úlohy nemáte oprávnenie", + "password-required": "Ak nie ste administrátor, musíte na vykonanie zmien vo vašom profile zadať vaše aktuálne heslo", + "invalid-password": "Nesprávne heslo", + "invalid-token": "Nesprávny token", + "unable-to-reset-key": "Niečo sa pokazilo, kľúč nie je možné resetovať", + "invalid-payload": "Nesprávny payload", + "nothing-to-do": "Nič na vykonanie", + "share-multiple-emails": "Nemôžete zdielať e-maily medzi rôznymi účtami", + "generate-token": "Pri generovaní potvrdzovacieho tokenu e-mailu sa vyskytla chyba. Pozrite záznamy udalostí", + "age-restriction-update": "Pri aktualizovaní vekového obmedzenia sa vyskytla chyba", + "no-user": "Používateľ neexistuje", + "generic-user-update": "Aktualizácia používateľa prebehla s výnimkou", + "username-taken": "Používateľské meno už existuje", + "user-already-confirmed": "Používateľ je už potvrdený", + "user-already-registered": "Používateľ je už registrovaný ako {0}", + "user-already-invited": "Používateľ je už pod týmto e-mailom pozvaný a musí ešte prijať pozvanie.", + "generic-password-update": "Pri potvrdení nového hesla sa vyskytla neočakávaná chyba", + "generic-invite-user": "Pri pozývaní tohto používateľa sa vyskytla chyba. Pozrite záznamy udalostí.", + "password-updated": "Heslo aktualizované", + "forgot-password-generic": "E-mail bude odoslaný na zadanú adresu len v prípade, ak existuje v databáze", + "invalid-email-confirmation": "Neplatné potvrdenie e-mailu", + "not-accessible-password": "Váš server nie je dostupný. Odkaz na resetovanie vášho hesla je v záznamoch udalostí", + "email-taken": "Zadaný e-mail už existuje", + "denied": "Nepovolené", + "manual-setup-fail": "Manuálne nastavenie nie je možné dokončiť. Prosím zrušte aktuálny postup a znovu vytvorte pozvánku", + "generic-user-email-update": "Nemožno aktualizovať e-mail používateľa. Skontrolujte záznamy udalostí.", + "email-not-enabled": "E-mail nie je na tomto serveri povolený. Preto túto akciu nemôžete vykonať.", + "collection-updated": "Zbierka bola úspešne aktualizovaná", + "device-doesnt-exist": "Zariadenie neexistuje", + "generic-device-delete": "Pri odstraňovaní zariadenia sa vyskytla chyba", + "greater-0": "{0} musí byť väčší ako 0", + "send-to-size-limit": "Snažíte sa odoslať súbor(y), ktoré sú príliš veľké pre vášho e-mailového poskytovateľa", + "send-to-device-status": "Prenos súborov do vášho zariadenia", + "no-cover-image": "Žiadny prebal", + "must-be-defined": "{0} musí byť definovaný", + "generic-favicon": "Pri získavaní favicon-u domény sa vyskytla chyba", + "no-library-access": "Pozužívateľ nemá prístup do tejto knižnice", + "user-doesnt-exist": "Používateľ neexistuje", + "collection-already-exists": "Zbierka už existuje", + "not-accessible": "Váš server nie je dostupný z vonkajšieho prostredia", + "email-sent": "E-mail odoslaný", + "user-migration-needed": "Uvedený používateľ potrebuje migrovať. Odhláste ho a opäť prihláste na spustenie migrácie", + "generic-invite-email": "Pri opakovanom odosielaní pozývacieho e-mailu sa vyskytla chyba", + "email-settings-invalid": "V nastaveniach e-mailu chýbajú potrebné údaje. Uistite sa, že všetky nastavenia e-mailu sú uložené.", + "chapter-doesnt-exist": "Kapitola neexistuje", + "critical-email-migration": "Počas migrácie e-mailu sa vyskytla chyba. Kontaktujte podporu", + "collection-deleted": "Zbierka bola vymazaná", + "generic-error": "Niečo sa pokazilo, skúste to znova", + "collection-doesnt-exist": "Zbierka neexistuje", + "generic-device-update": "Pri aktualizácii zariadenia sa vyskytla chyba", + "bookmark-doesnt-exist": "Záložka neexistuje", + "person-doesnt-exist": "Osoba neexistuje", + "send-to-kavita-email": "Odoslanie do zariadenia nemôže byť použité bez nastavenia e-amilu", + "send-to-unallowed": "Nemôžete odosielať do zariadenia, ktoré nie je vaše", + "generic-library": "Vyskytla sa kritická chyba. Prosím skúste to opäť.", + "pdf-doesnt-exist": "PDF neexistuje, hoci by malo", + "generic-library-update": "Počas aktualizácie knižnice sa vyskytla kritická chyba.", + "invalid-access": "Neplatný prístup", + "perform-scan": "Prosím, vykonajte opakovaný sken na tejto sérii alebo knižnici", + "generic-read-progress": "Pri ukladaní aktuálneho stavu sa vyskytla chyba", + "generic-clear-bookmarks": "Záložky nie je možné vymazať", + "bookmark-permission": "Nemáte oprávnenie na vkladanie/odstraňovanie záložiek", + "bookmark-save": "Nemožno uložiť záložku", + "bookmarks-empty": "Záložky nemôžu byť prázdne", + "library-doesnt-exist": "Knižnica neexistuje", + "invalid-path": "Neplatné umiestnenie", + "generic-send-to": "Pri odosielaní súboru(-ov) do vášho zariadenia sa vyskytla chyba", + "no-image-for-page": "Žiadny taký obrázok pre stránku {0}. Pokúste sa ju obnoviť, aby ste ju mohli nanovo uložiť.", + "delete-library-while-scan": "Nemôžete odstrániť knižnicu počas prebiehajúceho skenovania. Prosím, vyčkajte na dokončenie skenovania alebo reštartujte Kavitu a skúste ju opäť odstrániť", + "invalid-username": "Neplatné používateľské meno", + "account-email-invalid": "E-mail uvedený v údajoch administrátora nie je platným e-mailom. Nie je možné zaslať testovací e-mail.", + "admin-already-exists": "Administrátor už existuje", + "invalid-filename": "Neplatný názov súboru", + "file-doesnt-exist": "Súbor neexistuje", + "invalid-email": "E-mail v záznamoch pre používateľov nie platný e-mail. Odkazy sú uvedené v záznamoch udalostí.", + "file-missing": "Súbor nebol nájdený v knihe", + "error-import-stack": "Pri importovaní MAL balíka sa vyskytla chyba", + "person-name-required": "Meno osoby je povinné a nesmie byť prázdne", + "person-name-unique": "Meno osoby musí byť jedinečné", + "person-image-doesnt-exist": "Osoba neexistuje v databáze CoversDB", + "generic-device-create": "Pri vytváraní zariadenia sa vyskytla chyba", + "series-doesnt-exist": "Séria neexistuje", + "volume-doesnt-exist": "Zväzok neexistuje", + "library-name-exists": "Názov knižnice už existuje. Prosím, vyberte si pre daný server jedinečný názov.", + "cache-file-find": "Nepodarilo sa nájsť obrázok vo vyrovnávacej pamäti. Znova načítajte a skúste to znova.", + "name-required": "Názov nemôže byť prázdny", + "valid-number": "Musí to byť platné číslo strany", + "duplicate-bookmark": "Duplicitný záznam záložky už existuje", + "reading-list-permission": "Nemáte povolenia na tento zoznam na čítanie alebo zoznam neexistuje", + "reading-list-position": "Nepodarilo sa aktualizovať pozíciu", + "reading-list-updated": "Aktualizované", + "reading-list-item-delete": "Položku(y) sa nepodarilo odstrániť", + "reading-list-deleted": "Zoznam na čítanie bol odstránený", + "generic-reading-list-delete": "Pri odstraňovaní zoznamu na čítanie sa vyskytol problém", + "generic-reading-list-update": "Pri aktualizácii zoznamu na čítanie sa vyskytol problém", + "generic-reading-list-create": "Pri vytváraní zoznamu na čítanie sa vyskytol problém", + "reading-list-doesnt-exist": "Zoznam na čítanie neexistuje", + "series-restricted": "Používateľ nemá prístup k tejto sérii", + "generic-scrobble-hold": "Pri pauznutí funkcie sa vyskytla chyba", + "libraries-restricted": "Používateľ nemá prístup k žiadnym knižniciam", + "no-series": "Nepodarilo sa získať sériu pre knižnicu", + "no-series-collection": "Nepodarilo sa získať sériu pre kolekciu", + "generic-series-delete": "Pri odstraňovaní série sa vyskytol problém", + "generic-series-update": "Pri aktualizácii série sa vyskytla chyba", + "series-updated": "Úspešne aktualizované", + "update-metadata-fail": "Nepodarilo sa aktualizovať metadáta", + "age-restriction-not-applicable": "Bez obmedzenia", + "generic-relationship": "Pri aktualizácii vzťahov sa vyskytol problém", + "job-already-running": "Úloha už beží", + "encode-as-warning": "Nedá sa konvertovať do formátu PNG. Pre obaly použite možnosť Obnoviť obaly. Záložky a favicony sa nedajú spätne zakódovať.", + "ip-address-invalid": "IP adresa „{0}“ je neplatná", + "bookmark-dir-permissions": "Adresár záložiek nemá správne povolenia pre použitie v aplikácii Kavita", + "total-backups": "Celkový počet záloh musí byť medzi 1 a 30", + "total-logs": "Celkový počet protokolov musí byť medzi 1 a 30", + "stats-permission-denied": "Nemáte oprávnenie zobraziť si štatistiky iného používateľa", + "url-not-valid": "URL nevracia platný obrázok alebo vyžaduje autorizáciu", + "url-required": "Na použitie musíte zadať URL adresu", + "generic-cover-series-save": "Obrázok obálky sa nepodarilo uložiť do série", + "generic-cover-collection-save": "Obrázok obálky sa nepodarilo uložiť do kolekcie", + "generic-cover-reading-list-save": "Obrázok obálky sa nepodarilo uložiť do zoznamu na čítanie", + "generic-cover-chapter-save": "Obrázok obálky sa nepodarilo uložiť do kapitoly", + "generic-cover-library-save": "Obrázok obálky sa nepodarilo uložiť do knižnice", + "generic-cover-person-save": "Obrázok obálky sa nepodarilo uložiť k tejto osobe", + "generic-cover-volume-save": "Obrázok obálky sa nepodarilo uložiť do zväzku", + "access-denied": "Nemáte prístup", + "reset-chapter-lock": "Nepodarilo sa resetovať zámok obalu pre kapitolu", + "generic-user-delete": "Používateľa sa nepodarilo odstrániť", + "generic-user-pref": "Pri ukladaní predvolieb sa vyskytol problém", + "opds-disabled": "OPDS nie je na tomto serveri povolený", + "on-deck": "Pokračovať v čítaní", + "browse-on-deck": "Prehliadať pokračovanie v čítaní", + "recently-added": "Nedávno pridané", + "want-to-read": "Chcem čítať", + "browse-want-to-read": "Prehliadať Chcem si prečítať", + "browse-recently-added": "Prehliadať nedávno pridané", + "reading-lists": "Zoznamy na čítanie", + "browse-reading-lists": "Prehliadať podľa zoznamov na čítanie", + "libraries": "Všetky knižnice", + "browse-libraries": "Prehliadať podľa knižníc", + "collections": "Všetky kolekcie", + "browse-collections": "Prehliadať podľa kolekcií", + "more-in-genre": "Viac v žánri {0}", + "browse-more-in-genre": "Prezrite si viac v {0}", + "recently-updated": "Nedávno aktualizované", + "browse-recently-updated": "Prehliadať nedávno aktualizované", + "smart-filters": "Inteligentné filtre", + "external-sources": "Externé zdroje", + "browse-external-sources": "Prehliadať externé zdroje", + "browse-smart-filters": "Prehliadať podľa inteligentných filtrov", + "reading-list-restricted": "Zoznam na čítanie neexistuje alebo k nemu nemáte prístup", + "query-required": "Musíte zadať parameter dopytu", + "search": "Hľadať", + "search-description": "Vyhľadávanie sérií, zbierok alebo zoznamov na čítanie", + "favicon-doesnt-exist": "Favicon neexistuje", + "smart-filter-doesnt-exist": "Inteligentný filter neexistuje", + "smart-filter-already-in-use": "Existuje existujúci stream s týmto inteligentným filtrom", + "dashboard-stream-doesnt-exist": "Stream dashboardu neexistuje", + "sidenav-stream-doesnt-exist": "SideNav Stream neexistuje", + "external-source-already-exists": "Externý zdroj už existuje", + "external-source-required": "Vyžaduje sa kľúč API a Host", + "external-source-doesnt-exist": "Externý zdroj neexistuje", + "external-source-already-in-use": "S týmto externým zdrojom existuje stream", + "sidenav-stream-only-delete-smart-filter": "Z bočného panela SideNav je možné odstrániť iba streamy inteligentných filtrov", + "dashboard-stream-only-delete-smart-filter": "Z ovládacieho panela je možné odstrániť iba streamy inteligentných filtrov", + "smart-filter-name-required": "Názov inteligentného filtra je povinný", + "smart-filter-system-name": "Nemôžete použiť názov streamu poskytnutého systémom", + "not-authenticated": "Používateľ nie je overený", + "unable-to-register-k+": "Licenciu sa nepodarilo zaregistrovať z dôvodu chyby. Kontaktujte podporu Kavita+", + "unable-to-reset-k+": "Licenciu Kavita+ sa nepodarilo resetovať z dôvodu chyby. Kontaktujte podporu Kavita+", + "anilist-cred-expired": "Prihlasovacie údaje AniList vypršali alebo chýbajú", + "scrobble-bad-payload": "Nesprávne údaje od poskytovateľa Scrobblovania", + "theme-doesnt-exist": "Súbor témy chýba alebo je neplatný", + "bad-copy-files-for-download": "Súbory sa nepodarilo skopírovať do dočasného adresára na stiahnutie archívu.", + "generic-create-temp-archive": "Pri vytváraní dočasného archívu sa vyskytla chyba", + "epub-malformed": "Súbor je nesprávne naformátovaný! Nedá sa prečítať.", + "epub-html-missing": "Zodpovedajúci súbor HTML pre túto stránku sa nenašiel", + "collection-tag-title-required": "Názov kolekcie nemôže byť prázdny", + "reading-list-title-required": "Názov zoznamu na čítanie nemôže byť prázdny", + "collection-tag-duplicate": "Kolekcia s týmto názvom už existuje", + "device-duplicate": "Zariadenie s týmto názvom už existuje", + "device-not-created": "Toto zariadenie ešte neexistuje. Najprv ho vytvorte", + "send-to-permission": "Nie je možné odoslať súbory iné ako EPUB alebo PDF na zariadenia, pretože nie sú podporované na Kindle", + "progress-must-exist": "Pokrok musí byť u používateľa k dispozícii", + "reading-list-name-exists": "Zoznam na prečítanie s týmto menom už existuje", + "user-no-access-library-from-series": "Používateľ nemá prístup do knižnice, do ktorej táto séria patrí", + "series-restricted-age-restriction": "Používateľ si nemôže pozrieť túto sériu z dôvodu vekového obmedzenia", + "kavitaplus-restricted": "Toto je obmedzené iba na Kavita+", + "aliases-have-overlap": "Jeden alebo viacero aliasov sa prekrýva s inými osobami, nie je možné ich aktualizovať", + "volume-num": "Zväzok {0}", + "book-num": "Kniha {0}", + "issue-num": "Problém {0}{1}", + "chapter-num": "Kapitola {0}", + "check-updates": "Skontrolovať aktualizácie", + "license-check": "Kontrola licencie", + "process-scrobbling-events": "Udalosti procesu scrobblovania", + "report-stats": "Štatistiky hlásení", + "check-scrobbling-tokens": "Skontrolujte Tokeny Scrobblingu", + "cleanup": "Čistenie", + "process-processed-scrobbling-events": "Spracovať udalosti scrobblovania", + "remove-from-want-to-read": "Upratanie listu Chcem si prečítať", + "scan-libraries": "Skenovanie knižníc", + "kavita+-data-refresh": "Obnovenie údajov Kavita+", + "backup": "Záloha", + "update-yearly-stats": "Aktualizovať ročné štatistiky", + "generated-reading-profile-name": "Vygenerované z {0}" +} diff --git a/API/I18N/sl.json b/API/I18N/sl.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/API/I18N/sl.json @@ -0,0 +1 @@ +{} diff --git a/API/I18N/sv.json b/API/I18N/sv.json index 0ef70f997..64004a7a8 100644 --- a/API/I18N/sv.json +++ b/API/I18N/sv.json @@ -35,7 +35,7 @@ "generic-device-delete": "Det uppstod ett fel vid borttagning av enheten", "greater-0": "{0} måste vara större än 0", "send-to-kavita-email": "Skicka till enhet kan inte användas utan inställning av e-post", - "send-to-size-limit": "Filerna du försöker skicka är för stora för din e-post", + "send-to-size-limit": "Filerna du försöker skicka är för stora för din e-postleverantör", "generic-send-to": "Det uppstod ett fel vid sändningen av dina filer till enheten", "bookmarks-empty": "Bokmärken kan inte vara tomma", "series-doesnt-exist": "Serie saknas", @@ -110,7 +110,7 @@ "search-description": "Sök efter Serier, Samlingar, eller Läslistor", "favicon-doesnt-exist": "Favikon saknas", "smart-filter-doesnt-exist": "Smart Filter saknas", - "smart-filter-already-in-use": "Det finns redan en stream med detta Smart Filter", + "smart-filter-already-in-use": "Det finns redan en ström med detta Smarta Filter", "external-source-already-exists": "Extern Källa finns redan", "external-source-required": "ApiKey och Host krävs", "external-source-doesnt-exist": "Extern Källa saknas", @@ -118,7 +118,7 @@ "not-authenticated": "Användaren är inte autentiserad", "unable-to-reset-k+": "Det gick inte att återställa Kavita+-licensen på grund av ett fel. Kontakta Kavita+ Support", "anilist-cred-expired": "AniList Autentiseringsuppgifter har gått ut eller är inte angivna", - "scrobble-bad-payload": "Felaktig payload från Scrobble Provider", + "scrobble-bad-payload": "Felaktig payload från Scrobble utgivare", "theme-doesnt-exist": "Temafil saknas eller är ogiltig", "generic-create-temp-archive": "Det uppstod ett problem vid skapandet av tillfällig katalog", "epub-malformed": "Filen är felaktigt utformad! Kan inte läsa.", @@ -178,7 +178,7 @@ "browse-collections": "Bläddra efter Samlingar", "more-in-genre": "Mer i Genre {0}", "browse-more-in-genre": "Bläddra mer i {0}", - "unable-to-register-k+": "Kan inte registrera licens på grund av fel. Kontakta Kavita+ Support", + "unable-to-register-k+": "Kan inte registrera licens på grund av ett fel. Kontakta Kavita+ Support", "bad-copy-files-for-download": "Kan inte kopiera filer till temp-katalogen för arkivnedladdning.", "epub-html-missing": "Kunde inte hitta lämplig html för den sidan", "device-not-created": "Denna enhet finns inte än. Vänligen skapa den först", @@ -194,6 +194,18 @@ "cleanup": "Städning", "on-deck": "Fortsätt Läsa", "browse-on-deck": "Bläddra i Fortsätt Läsa", - "dashboard-stream-doesnt-exist": "Dashboard Stream saknas", - "sidenav-stream-doesnt-exist": "SideNav Stream saknas" + "dashboard-stream-doesnt-exist": "Dashboard Ström saknas", + "sidenav-stream-doesnt-exist": "SideNav Ström saknas", + "person-doesnt-exist": "Personen existerar inte", + "person-name-required": "Personnamn är obligatorisk", + "person-name-unique": "Personnamn måste vara unikt", + "person-image-doesnt-exist": "Personen existerar inte i CoversDB", + "generic-cover-person-save": "Kan inte spara omslagsbilden till Personen", + "generic-cover-volume-save": "Kan inte spara omslagsbilden till Volymen", + "email-taken": "E-postadressen används redan", + "kavitaplus-restricted": "Detta är enbart tillgängligt med Kavita+", + "dashboard-stream-only-delete-smart-filter": "Bara smarta-filter-strömmar kan tas bort från panelen", + "smart-filter-name-required": "Smart Filter namn krävs", + "sidenav-stream-only-delete-smart-filter": "Bara smarta-filter-strömmar kan tas bort från sidomenyn", + "smart-filter-system-name": "Du kan inte använda namnet från en ström som systemet tillhandahåller" } diff --git a/API/I18N/ta.json b/API/I18N/ta.json new file mode 100644 index 000000000..cc20ab40f --- /dev/null +++ b/API/I18N/ta.json @@ -0,0 +1,213 @@ +{ + "generic-invite-user": "பயனரை அழைக்கும் சிக்கல் இருந்தது. பதிவுகளை சரிபார்க்கவும்.", + "user-already-invited": "பயனர் ஏற்கனவே இந்த மின்னஞ்சலின் கீழ் அழைக்கப்படுகிறார், மேலும் அழைப்பை இன்னும் ஏற்கவில்லை.", + "invalid-email-confirmation": "தவறான மின்னஞ்சல் உறுதிப்படுத்தல்", + "generic-user-email-update": "பயனருக்கான மின்னஞ்சலைப் புதுப்பிக்க முடியவில்லை. பதிவுகளை சரிபார்க்கவும்.", + "generic-password-update": "புதிய கடவுச்சொல்லை உறுதிப்படுத்தும்போது எதிர்பாராத பிழை ஏற்பட்டது", + "password-updated": "கடவுச்சொல் புதுப்பிக்கப்பட்டது", + "forgot-password-generic": "எங்கள் தரவுத்தளத்தில் இருந்தால் மின்னஞ்சல் அனுப்பப்படும்", + "bad-copy-files-for-download": "கோப்புகளை தற்காலிக அடைவு காப்பக பதிவிறக்கத்திற்கு நகலெடுக்க முடியவில்லை.", + "generic-create-temp-archive": "தற்காலிக காப்பகத்தை உருவாக்கும் சிக்கல் இருந்தது", + "epub-malformed": "கோப்பு தவறாக உள்ளது! படிக்க முடியாது.", + "epub-html-missing": "அந்தப் பக்கத்திற்கு பொருத்தமான உஉகுமொ ஐக் கண்டுபிடிக்க முடியவில்லை", + "collection-tag-title-required": "சேகரிப்பு தலைப்பு காலியாக இருக்க முடியாது", + "collection-tag-duplicate": "இந்த பெயருடன் ஒரு தொகுப்பு ஏற்கனவே உள்ளது", + "device-duplicate": "இந்த பெயரைக் கொண்ட ஒரு சாதனம் ஏற்கனவே உள்ளது", + "not-accessible-password": "உங்கள் சேவையகம் அணுக முடியாது. உங்கள் கடவுச்சொல்லை மீட்டமைப்பதற்கான இணைப்பு பதிவுகளில் உள்ளது", + "device-not-created": "இந்த சாதனம் இன்னும் இல்லை. முதலில் உருவாக்கவும்", + "send-to-permission": "கின்டலில் ஆதரிக்கப்படாத சாதனங்களுக்கு எபப் அல்லாத அல்லது பி.டி.எஃப் அனுப்ப முடியாது", + "progress-must-exist": "பயனரில் முன்னேற்றம் இருக்க வேண்டும்", + "reading-list-name-exists": "இந்த பெயரின் வாசிப்பு பட்டியல் ஏற்கனவே உள்ளது", + "user-no-access-library-from-series": "பயனருக்கு நூலகத்திற்கு அணுகல் இல்லை இந்த தொடர் சொந்தமானது", + "series-restricted-age-restriction": "அகவை கட்டுப்பாடுகள் காரணமாக இந்தத் தொடரைப் பார்க்க பயனருக்கு இசைவு இல்லை", + "volume-num": "தொகுதி {0}", + "book-num": "நூல் {0}", + "issue-num": "வெளியீடு {0} {1}", + "chapter-num": "அத்தியாயம் {0}", + "check-updates": "புதுப்பிப்புகளை சரிபார்க்கவும்", + "license-check": "உரிம சோதனை", + "process-scrobbling-events": "செயலாக்க நிகழ்வுகளை செயலாக்கவும்", + "report-stats": "புள்ளிவிவரங்களைப் புகாரளிக்கவும்", + "check-scrobbling-tokens": "ச்க்ரோப்ளிங் டோக்கன்களை சரிபார்க்கவும்", + "cleanup": "தூய்மைப்படுத்துதல்", + "process-processed-scrobbling-events": "செயல்முறை செயலாக்கப்பட்ட ச்க்ரோப்லிங் நிகழ்வுகள்", + "remove-from-want-to-read": "தூய்மைப்படுத்தலைப் படிக்க விரும்புகிறேன்", + "scan-libraries": "நூலகங்களை ச்கேன் செய்யுங்கள்", + "kavita+-data-refresh": "கவிதா+ தரவு புதுப்பிப்பு", + "backup": "காப்புப்பிரதி", + "update-yearly-stats": "ஆண்டு புள்ளிவிவரங்களைப் புதுப்பிக்கவும்", + "invalid-email": "பயனருக்கான கோப்பில் உள்ள மின்னஞ்சல் சரியான மின்னஞ்சல் அல்ல. எந்த இணைப்புகளுக்கான பதிவுகளையும் காண்க.", + "not-accessible": "உங்கள் சேவையகம் வெளிப்புறமாக அணுக முடியாது", + "email-sent": "மின்னஞ்சல் அனுப்பப்பட்டது", + "user-migration-needed": "இந்த பயனர் இடம்பெயர வேண்டும். இடம்பெயர்வு ஓட்டத்தைத் தூண்டுவதற்கு அவை வெளியேறி உள்நுழைய வேண்டும்", + "generic-invite-email": "அழைப்பு மின்னஞ்சல் மீண்டும் ஒரு சிக்கல் இருந்தது", + "admin-already-exists": "நிர்வாகி ஏற்கனவே உள்ளது", + "invalid-username": "தவறான பயனர்பெயர்", + "critical-email-migration": "மின்னஞ்சல் இடம்பெயர்வு போது ஒரு சிக்கல் இருந்தது. தொடர்பு உதவி", + "email-not-enabled": "இந்த சேவையகத்தில் மின்னஞ்சல் இயக்கப்படவில்லை. இந்த செயலை நீங்கள் செய்ய முடியாது.", + "must-be-defined": "{0} வரையறுக்கப்பட வேண்டும்", + "generic-favicon": "டொமைனுக்கு ஃபேவிகானைப் பெறுவதில் சிக்கல் இருந்தது", + "invalid-filename": "தவறான கோப்பு பெயர்", + "file-doesnt-exist": "கோப்பு இல்லை", + "library-name-exists": "நூலக பெயர் ஏற்கனவே உள்ளது. சேவையகத்திற்கு ஒரு தனிப்பட்ட பெயரைத் தேர்வுசெய்க.", + "generic-library": "ஒரு முக்கியமான சிக்கல் இருந்தது. மீண்டும் முயற்சிக்கவும்.", + "no-library-access": "பயனருக்கு இந்த நூலகத்திற்கு அணுகல் இல்லை", + "library-doesnt-exist": "நூலகம் இல்லை", + "invalid-path": "தவறான பாதை", + "no-series-collection": "சேகரிப்புக்கான தொடர்களைப் பெற முடியவில்லை", + "generic-series-delete": "தொடரை நீக்குவதில் சிக்கல் இருந்தது", + "generic-series-update": "தொடரைப் புதுப்பிப்பதில் பிழை ஏற்பட்டது", + "series-updated": "வெற்றிகரமாக புதுப்பிக்கப்பட்டது", + "update-metadata-fail": "மெட்டாடேட்டாவை புதுப்பிக்க முடியவில்லை", + "age-restriction-not-applicable": "கட்டுப்பாடு இல்லை", + "generic-relationship": "உறவுகளைப் புதுப்பிப்பதில் சிக்கல் இருந்தது", + "job-already-running": "ஏற்கனவே இயங்கும் வேலை", + "browse-reading-lists": "பட்டியல்களைப் படிப்பதன் மூலம் உலாவுக", + "libraries": "அனைத்து நூலகங்களும்", + "browse-libraries": "நூலகங்களால் உலாவுக", + "collections": "அனைத்து சேகரிப்புகளும்", + "browse-collections": "வசூல் மூலம் உலாவுக", + "more-in-genre": "{0} வகைகளில் மேலும்", + "browse-more-in-genre": "{0} இல் மேலும் உலாவுக", + "recently-updated": "அண்மைக் காலத்தில் புதுப்பிக்கப்பட்டது", + "browse-recently-updated": "உலாவு அண்மைக் காலத்தில் புதுப்பிக்கப்பட்டது", + "smart-filters": "அறிவுள்ள வடிப்பான்கள்", + "external-sources": "வெளிப்புற ஆதாரங்கள்", + "browse-external-sources": "வெளிப்புற ஆதாரங்களை உலாவுக", + "browse-smart-filters": "அறிவுள்ள வடிப்பான்களால் உலாவுக", + "reading-list-restricted": "வாசிப்பு பட்டியல் இல்லை அல்லது உங்களுக்கு அணுகல் இல்லை", + "query-required": "நீங்கள் ஒரு வினவல் அளவுருவை அனுப்ப வேண்டும்", + "search-description": "தொடர், தொகுப்புகள் அல்லது வாசிப்பு பட்டியல்களைத் தேடுங்கள்", + "favicon-doesnt-exist": "ஃபாவிகான் இல்லை", + "smart-filter-doesnt-exist": "அறிவுள்ள வடிகட்டி இல்லை", + "smart-filter-already-in-use": "இந்த அறிவுள்ள வடிகட்டியுடன் ஏற்கனவே ச்ட்ரீம் உள்ளது", + "dashboard-stream-doesnt-exist": "டாச்போர்டு ச்ட்ரீம் இல்லை", + "sidenav-stream-doesnt-exist": "சிடெனவ் ச்ட்ரீம் இல்லை", + "external-source-already-exists": "வெளிப்புற மூலமானது ஏற்கனவே உள்ளது", + "external-source-required": "அப்பிகி மற்றும் புரவலன் தேவை", + "external-source-doesnt-exist": "வெளிப்புற மூல இல்லை", + "external-source-already-in-use": "இந்த வெளிப்புற மூலத்துடன் ஏற்கனவே ச்ட்ரீம் உள்ளது", + "not-authenticated": "பயனர் அங்கீகரிக்கப்படவில்லை", + "unable-to-register-k+": "பிழை காரணமாக உரிமத்தை பதிவு செய்ய முடியவில்லை. கவிதா+ ஆதரவை அணுகவும்", + "unable-to-reset-k+": "Unable பெறுநர் மீட்டமை Kavita+ உரிமம் due பெறுநர் error. கவிதா+ ஆதரவை அணுகவும்", + "anilist-cred-expired": "அனிலிச்ட் நற்சான்றிதழ்கள் காலாவதியானன அல்லது அமைக்கப்படவில்லை", + "scrobble-bad-payload": "ச்க்ரோபல் வழங்குநரிடமிருந்து மோசமான பேலோட்", + "theme-doesnt-exist": "கருப்பொருள் கோப்பு காணவில்லை அல்லது தவறானது", + "search": "தேடல்", + "age-restriction-update": "அகவை கட்டுப்பாட்டை புதுப்பிப்பதில் பிழை ஏற்பட்டது", + "bookmark-doesnt-exist": "புக்மார்க்கு இல்லை", + "user-doesnt-exist": "பயனர் இல்லை", + "reading-list-item-delete": "உருப்படி (களை) நீக்க முடியவில்லை", + "libraries-restricted": "பயனருக்கு எந்த நூலகங்களுக்கும் அணுகல் இல்லை", + "reading-list-title-required": "பட்டியல் தலைப்பு காலியாக இருக்க முடியாது", + "confirm-email": "முதலில் உங்கள் மின்னஞ்சலை உறுதிப்படுத்த வேண்டும்", + "locked-out": "பல அங்கீகார முயற்சிகளிலிருந்து நீங்கள் பூட்டப்பட்டுள்ளீர்கள். தயவுசெய்து 10 நிமிடங்கள் காத்திருக்கவும்.", + "disabled-account": "உங்கள் கணக்கு முடக்கப்பட்டுள்ளது. சேவையக நிர்வாகியைத் தொடர்பு கொள்ளுங்கள்.", + "register-user": "பயனரை பதிவு செய்யும் போது ஏதோ தவறு ஏற்பட்டது", + "validate-email": "உங்கள் மின்னஞ்சலை உறுதிப்படுத்தும் சிக்கல் இருந்தது: {0}", + "confirm-token-gen": "உறுதிப்படுத்தல் கிள்ளாக்கை உருவாக்கும் சிக்கல் இருந்தது", + "denied": "அனுமதிக்கப்படவில்லை", + "permission-denied": "இந்த நடவடிக்கைக்கு நீங்கள் அனுமதிக்கப்படவில்லை", + "password-required": "நீங்கள் ஒரு நிர்வாகி இல்லையென்றால் உங்கள் கணக்கை மாற்ற உங்கள் ஏற்கனவே உள்ள கடவுச்சொல்லை உள்ளிட வேண்டும்", + "invalid-password": "தவறான கடவுச்சொல்", + "invalid-token": "தவறான கிள்ளாக்கு", + "unable-to-reset-key": "விசையை மீட்டமைக்க முடியாமல் ஏதோ தவறு நடந்தது", + "invalid-payload": "தவறான பேலோட்", + "nothing-to-do": "செய்ய எதுவும் இல்லை", + "share-multiple-emails": "பல கணக்குகளில் மின்னஞ்சல்களைப் பகிர முடியாது", + "generate-token": "உறுதிப்படுத்தல் மின்னஞ்சல் கிள்ளாக்கை உருவாக்கும் சிக்கல் இருந்தது. பதிவுகள் பார்க்கவும்", + "no-user": "பயனர் இல்லை", + "username-taken": "ஏற்கனவே எடுக்கப்பட்ட பயனர்பெயர்", + "email-taken": "ஏற்கனவே பயன்பாட்டில் உள்ள மின்னஞ்சல்", + "user-already-confirmed": "பயனர் ஏற்கனவே உறுதிப்படுத்தப்பட்டுள்ளது", + "generic-user-update": "பயனரைப் புதுப்பிக்கும்போது விதிவிலக்கு இருந்தது", + "manual-setup-fail": "கையேடு அமைப்பை முடிக்க முடியவில்லை. தயவுசெய்து அழைப்பை ரத்து செய்து மீண்டும் உருவாக்கவும்", + "user-already-registered": "பயனர் ஏற்கனவே {0 அச் என பதிவு செய்யப்பட்டுள்ளார்", + "account-email-invalid": "நிர்வாகக் கணக்கிற்கான கோப்பில் உள்ள மின்னஞ்சல் சரியான மின்னஞ்சல் அல்ல. சோதனை மின்னஞ்சலை அனுப்ப முடியாது.", + "email-settings-invalid": "மின்னஞ்சல் அமைப்புகள் காணவில்லை. அனைத்து மின்னஞ்சல் அமைப்புகளும் சேமிக்கப்படுவதை உறுதிசெய்க.", + "chapter-doesnt-exist": "அத்தியாயம் இல்லை", + "file-missing": "கோப்பு புத்தகத்தில் காணப்படவில்லை", + "collection-updated": "சேகரிப்பு வெற்றிகரமாக புதுப்பிக்கப்பட்டது", + "collection-deleted": "சேகரிப்பு நீக்கப்பட்டது", + "generic-error": "ஏதோ தவறு நடந்தது, தயவுசெய்து மீண்டும் முயற்சிக்கவும்", + "collection-doesnt-exist": "சேகரிப்பு இல்லை", + "collection-already-exists": "சேகரிப்பு ஏற்கனவே உள்ளது", + "error-import-stack": "மால் ச்டேக்கை இறக்குமதி செய்வதில் சிக்கல் இருந்தது", + "person-doesnt-exist": "நபர் இல்லை", + "person-name-required": "நபரின் பெயர் தேவை மற்றும் பூச்யமாக இருக்கக்கூடாது", + "person-name-unique": "நபரின் பெயர் தனித்துவமாக இருக்க வேண்டும்", + "person-image-doesnt-exist": "கவர்ச்டிபியில் நபர் இல்லை", + "device-doesnt-exist": "சாதனம் இல்லை", + "generic-device-create": "சாதனத்தை உருவாக்கும் போது பிழை ஏற்பட்டது", + "generic-device-update": "சாதனத்தைப் புதுப்பிக்கும்போது பிழை ஏற்பட்டது", + "generic-device-delete": "சாதனத்தை நீக்கும்போது பிழை ஏற்பட்டது", + "greater-0": "{0} 0 ஐ விட அதிகமாக இருக்க வேண்டும்", + "send-to-kavita-email": "மின்னஞ்சல் அமைவு இல்லாமல் சாதனத்திற்கு அனுப்பு பயன்படுத்த முடியாது", + "send-to-unallowed": "உங்களுடையது இல்லாத சாதனத்திற்கு நீங்கள் அனுப்ப முடியாது", + "send-to-size-limit": "நீங்கள் அனுப்ப முயற்சிக்கும் கோப்பு (கள்) உங்கள் மின்னஞ்சல் வழங்குநருக்கு மிகப் பெரியது", + "send-to-device-status": "உங்கள் சாதனத்திற்கு கோப்புகளை மாற்றுகிறது", + "generic-send-to": "சாதனத்திற்கு கோப்பு (களை) அனுப்புவதில் பிழை ஏற்பட்டது", + "series-doesnt-exist": "தொடர் இல்லை", + "volume-doesnt-exist": "தொகுதி இல்லை", + "bookmarks-empty": "புக்மார்க்குகள் காலியாக இருக்க முடியாது", + "no-cover-image": "கவர் படம் இல்லை", + "delete-library-while-scan": "ச்கேன் நடந்து கொண்டிருக்கும்போது நீங்கள் ஒரு நூலகத்தை நீக்க முடியாது. கவிதாவை ச்கேன் முடிக்க அல்லது மறுதொடக்கம் செய்ய காத்திருங்கள்", + "generic-library-update": "நூலகத்தைப் புதுப்பிப்பதில் ஒரு முக்கியமான சிக்கல் இருந்தது.", + "pdf-doesnt-exist": "பி.டி.எஃப் எப்போது இருக்க வேண்டும்", + "invalid-access": "தவறான அணுகல்", + "no-image-for-page": "பக்கத்திற்கு அத்தகைய படம் இல்லை {0}. மறு கேச் அனுமதிக்க புத்துணர்ச்சியை முயற்சிக்கவும்.", + "perform-scan": "தயவுசெய்து இந்த தொடர் அல்லது நூலகத்தில் ச்கேன் செய்து மீண்டும் முயற்சிக்கவும்", + "generic-read-progress": "முன்னேற்றத்தை மிச்சப்படுத்தும் சிக்கல் இருந்தது", + "generic-clear-bookmarks": "புக்மார்க்குகளை அழிக்க முடியவில்லை", + "bookmark-permission": "புக்மார்க்கு/புத்தகமார்க்குக்கு உங்களுக்கு இசைவு இல்லை", + "bookmark-save": "புத்தகக்குறியை சேமிக்க முடியவில்லை", + "cache-file-find": "தற்காலிக சேமிப்பு படத்தைக் கண்டுபிடிக்க முடியவில்லை. மீண்டும் ஏற்றவும் மீண்டும் முயற்சிக்கவும்.", + "name-required": "பெயர் காலியாக இருக்க முடியாது", + "valid-number": "செல்லுபடியாகும் பக்க எண்ணாக இருக்க வேண்டும்", + "duplicate-bookmark": "நகல் புத்தகக்குறி நுழைவு ஏற்கனவே உள்ளது", + "reading-list-permission": "இந்த வாசிப்பு பட்டியலில் உங்களிடம் இசைவு இல்லை அல்லது பட்டியல் இல்லை", + "reading-list-position": "நிலையை புதுப்பிக்க முடியவில்லை", + "reading-list-updated": "புதுப்பிக்கப்பட்டது", + "reading-list-deleted": "வாசிப்பு பட்டியல் நீக்கப்பட்டது", + "generic-reading-list-delete": "வாசிப்பு பட்டியலை நீக்குவதில் சிக்கல் இருந்தது", + "generic-reading-list-update": "வாசிப்பு பட்டியலைப் புதுப்பிப்பதில் சிக்கல் இருந்தது", + "generic-reading-list-create": "வாசிப்பு பட்டியலை உருவாக்கும் சிக்கல் இருந்தது", + "reading-list-doesnt-exist": "வாசிப்பு பட்டியல் இல்லை", + "series-restricted": "பயனருக்கு இந்த தொடருக்கான அணுகல் இல்லை", + "generic-scrobble-hold": "பிடியைச் சேர்க்கும்போது பிழை ஏற்பட்டது", + "no-series": "நூலகத்திற்கான தொடர்களைப் பெற முடியவில்லை", + "encode-as-warning": "நீங்கள் பி.என்.சி.க்கு மாற்ற முடியாது. அட்டைகளுக்கு, புதுப்பிப்பு அட்டைகளைப் பயன்படுத்தவும். புக்மார்க்குகள் மற்றும் ஃபாவிகான்களை மீண்டும் குறியாக்கம் செய்ய முடியாது.", + "ip-address-invalid": "ஐபி முகவரி '{0}' தவறானது", + "bookmark-dir-permissions": "கவிதாவைப் பயன்படுத்த புக்மார்க்கு கோப்பகத்திற்கு சரியான அனுமதிகள் இல்லை", + "total-backups": "மொத்த காப்புப்பிரதிகள் 1 முதல் 30 வரை இருக்க வேண்டும்", + "total-logs": "மொத்த பதிவுகள் 1 முதல் 30 வரை இருக்க வேண்டும்", + "stats-permission-denied": "மற்றொரு பயனரின் புள்ளிவிவரங்களைக் காண உங்களுக்கு ஏற்பு இல்லை", + "url-not-valid": "முகவரி சரியான படத்தை திருப்பித் தராது அல்லது ஏற்பு தேவைப்படுகிறது", + "url-required": "பயன்படுத்த நீங்கள் ஒரு முகவரி ஐ அனுப்ப வேண்டும்", + "generic-cover-series-save": "கவர் படத்தை தொடருக்கு சேமிக்க முடியவில்லை", + "generic-cover-collection-save": "கவர் படத்தை சேகரிப்புக்கு சேமிக்க முடியவில்லை", + "generic-cover-reading-list-save": "கவர் படத்தை வாசிப்பு பட்டியலுக்கு சேமிக்க முடியவில்லை", + "generic-cover-chapter-save": "கவர் படத்தை அத்தியாயத்தில் சேமிக்க முடியவில்லை", + "generic-cover-library-save": "கவர் படத்தை நூலகத்தில் சேமிக்க முடியவில்லை", + "generic-cover-person-save": "கவர் படத்தை நபருக்கு சேமிக்க முடியவில்லை", + "generic-cover-volume-save": "கவர் படத்தை தொகுதிக்கு சேமிக்க முடியவில்லை", + "access-denied": "உங்களுக்கு அணுகல் இல்லை", + "reset-chapter-lock": "அத்தியாயத்திற்கான கவர் பூட்டை மீட்டமைக்க முடியவில்லை", + "generic-user-delete": "பயனரை நீக்க முடியவில்லை", + "generic-user-pref": "விருப்பங்களை சேமிக்கும் சிக்கல் இருந்தது", + "opds-disabled": "இந்த சேவையகத்தில் OPDS இயக்கப்படவில்லை", + "on-deck": "டெக்கில்", + "browse-on-deck": "டெக்கில் உலாவுக", + "recently-added": "அண்மைக் காலத்தில் சேர்க்கப்பட்டது", + "want-to-read": "படிக்க விரும்புகிறேன்", + "browse-want-to-read": "உலாவு படிக்க விரும்புகிறது", + "browse-recently-added": "உலாவு அண்மைக் காலத்தில் சேர்க்கப்பட்டது", + "reading-lists": "பட்டியல்களைப் படித்தல்", + "sidenav-stream-only-delete-smart-filter": "சைடனாவிலிருந்து அறிவுள்ள வடிகட்டி நீரோடைகளை மட்டுமே நீக்க முடியும்", + "dashboard-stream-only-delete-smart-filter": "டாச்போர்டில் இருந்து அறிவுள்ள வடிகட்டி ச்ட்ரீம்களை மட்டுமே நீக்க முடியும்", + "smart-filter-name-required": "அறிவுள்ள வடிகட்டி பெயர் தேவை", + "smart-filter-system-name": "வழங்கப்பட்ட ச்ட்ரீமின் பெயரை நீங்கள் பயன்படுத்த முடியாது", + "kavitaplus-restricted": "இது கவிதா+ க்கு மட்டுமே", + "aliases-have-overlap": "ஒன்று அல்லது அதற்கு மேற்பட்ட மாற்றுப்பெயர்கள் மற்றவர்களுடன் ஒன்றுடன் ஒன்று உள்ளன, புதுப்பிக்க முடியாது", + "generated-reading-profile-name": "{0 இருந்து இலிருந்து உருவாக்கப்பட்டது" +} diff --git a/API/I18N/tr.json b/API/I18N/tr.json index fb72952b1..370f13f20 100644 --- a/API/I18N/tr.json +++ b/API/I18N/tr.json @@ -3,7 +3,7 @@ "permission-denied": "Bu operasyona izniniz yok", "confirm-email": "İlk olarak E-Posta'nı onaylaman gerek", "register-user": "Kullanıcıyı kayıt ederken bir şeyler yanlış gitti", - "disabled-account": "Hesabınız devre dışı bırakıldı. Sunucu yöneticisiyle iletişime geçin.", + "disabled-account": "Hesabınız devre dışı. Sunucu yöneticisiyle iletişime geçin.", "validate-email": "E-Posta'yı doğrularken bir hata oluştu: {0}", "confirm-token-gen": "Doğrulama tokeni oluşturulurken bir sorun oluştu", "password-required": "Yönetici değilseniz, hesabınızı değiştirmek için mevcut şifrenizi girmelisiniz", diff --git a/API/I18N/uk.json b/API/I18N/uk.json index a8afe85a9..62f514a04 100644 --- a/API/I18N/uk.json +++ b/API/I18N/uk.json @@ -13,5 +13,12 @@ "nothing-to-do": "Тут нема роботи", "share-multiple-emails": "Не можна користуватися одним email з кількох облікових записів", "confirm-token-gen": "Щось пішло не так під час генерації коду підтвердження", - "invalid-password": "Неправильний пароль" + "invalid-password": "Неправильний пароль", + "generic-user-update": "Сталася помилка при спробі оновлення інформації користувача", + "generate-token": "Відбулась помилка при спробі підтвердження токену. Перегляньте логи", + "age-restriction-update": "Сталася помилка під час оновлення обмежень віку", + "no-user": "Користувача не існує", + "username-taken": "Ім'я користувача уже зайняте", + "user-already-confirmed": "Користувач уже підтверджений", + "email-taken": "Адреса електронної пошти уже зайнята" } diff --git a/API/I18N/vi.json b/API/I18N/vi.json index adeec3884..2fcd16154 100644 --- a/API/I18N/vi.json +++ b/API/I18N/vi.json @@ -57,7 +57,7 @@ "invalid-path": "Đường dẫn không hợp lệ", "delete-library-while-scan": "Không thể xóa thư viện trong lúc đang quét kiểm tra sách. Xin đợi quá trình quét hoàn tất hoặc khởi động lại Kavita để xóa", "collection-already-exists": "Bộ sưu tập đã tồn tại", - "send-to-size-limit": "Tập tin bạn gửi vượt quá dung lượng phần mềm email cho phép", + "send-to-size-limit": "Tệp tin bạn đang cố gắng gửi quá lớn đối với nhà cung cấp email của bạn", "send-to-device-status": "Đang chuyển tập tin qua thiết bị", "invalid-filename": "Tên tập tin không hợp lệ", "library-name-exists": "Tên thư viện đã tồn tại. Xin chọn một tên có 1-0-2 trong nội bộ máy chủ này.", @@ -76,7 +76,7 @@ "generic-library": "Có sự cố nghiêm trọng xảy ra. Xin thử lại sau.", "generic-library-update": "Có sự cố nghiêm trọng xảy ra khi cập nhật thư viện.", "bookmark-save": "Không thể lưu thẻ đánh dấu sách", - "cache-file-find": "Không thể tìm thấy hình ảnh đã cache. Tải trang và thử lại.", + "cache-file-find": "Không thể tìm thấy hình ảnh được lưu trong bộ nhớ đệm. Tải trang và thử lại.", "name-required": "Tên không được để trống", "valid-number": "Phải là một số trang hợp lệ", "duplicate-bookmark": "Trùng thẻ dánh dấu sách đã tồn tại", @@ -99,25 +99,25 @@ "recently-added": "Mới thêm vào", "update-metadata-fail": "Không thể cập nhật metadata", "age-restriction-not-applicable": "Không giới hạn", - "generic-relationship": "Có vấn đề khi cập nhật mối tương quan", + "generic-relationship": "Có vấn đề khi cập nhật mối quan hệ", "job-already-running": "Tác vụ đang chạy", "ip-address-invalid": "Địa chỉ IP '{0}' không hợp lệ", - "bookmark-dir-permissions": "Thư mục Thẻ đánh dấu sách không cấp đúng quyền cho Kavita sử dụng", + "bookmark-dir-permissions": "Thư mục Dấu trang không được cấp đúng quyền cho Kavita sử dụng", "total-backups": "Tổng số Sao lưu phải từ 1 đến 30", "total-logs": "Tổng số Log phải từ 1 đến 30", "generic-cover-reading-list-save": "Không thể lưu ảnh bìa vào Danh sách đọc", "generic-cover-chapter-save": "Không thể lưu ảnh bìa vào Chương", "reset-chapter-lock": "Không thể đặt lại khóa ảnh bìa cho Chương", - "device-not-created": "Thiết bị chưa tồn tại. Xin tạo mới", + "device-not-created": "Thiết bị chưa tồn tại. Vui lòng tạo thiết bị trước", "generic-cover-collection-save": "Không thể lưu ảnh bìa vào Bộ sưu tập", "generic-cover-person-save": "Không thể lưu ảnh bìa vào Người dùng", - "device-duplicate": "Trùng tên với một thiết bị hiện có", + "device-duplicate": "Đã có một thiết bị với tên này", "generic-cover-volume-save": "Không thể lưu ảnh bìa vào Tập", - "browse-on-deck": "Lướt trên kệ sách", + "browse-on-deck": "Duyệt sách Đang đọc", "want-to-read": "Muốn đọc", "browse-recently-updated": "Lướt Mới cập nhật", "browse-smart-filters": "Lướt theo Bộ lọc thông minh", - "search-description": "Tìm kiếm Chuỗi, Bộ sưu tập, hoặc Danh sách đọc", + "search-description": "Tìm kiếm Bộ Truyện, Bộ sưu tập, hoặc Danh sách đọc", "external-source-already-exists": "Đã có Nguồn bên ngoài này rồi", "epub-html-missing": "Không tìm được html thích hợp cho trang", "collection-tag-title-required": "Tiêu đề Bộ sưu tập không được để trống", @@ -125,20 +125,20 @@ "collection-tag-duplicate": "Trùng tên với bộ sưu tập hiện có", "generic-cover-library-save": "Không thể lưu ảnh bìa vào Thư viện", "access-denied": "Bạn không có quyền truy cập", - "generic-user-pref": "Có vấn đề khi lưu sở thích người dùng", + "generic-user-pref": "Có sự cố khi lưu tùy chọn", "browse-reading-lists": "Lướt theo Danh sách đọc", "browse-more-in-genre": "Lướt thêm trong {0}", "reading-list-restricted": "Danh sách đọc không tồn tại hoặc bạn không có quyền truy cập", - "query-required": "Bạn cần thêm vào một giá trị tìm kiếm", + "query-required": "Bạn phải truyền một tham số truy vấn", "favicon-doesnt-exist": "Favicon không tồn tại", "smart-filter-doesnt-exist": "Bộ lọc thông minh không tồn tại", "external-source-doesnt-exist": "Nguồn bên ngoài không tồn tại", "not-authenticated": "Người dùng không được cấp quyền", - "theme-doesnt-exist": "Tập tin chủ đề bị thiếu hoặc không hợp lệ", + "theme-doesnt-exist": "Tập tin hủ đề bị thiếu hoặc không hợp lệ", "series-restricted-age-restriction": "Người dùng không được xem chuỗi vì giới hạn độ tuổi", "chapter-num": "Chương {0}", "reading-list-name-exists": "Trùng tên với một danh sách đọc hiện có", - "user-no-access-library-from-series": "Người dùng không có quyền truy cập thư viện chứa chuỗi này", + "user-no-access-library-from-series": "Người dùng không có quyền truy cập vào thư viện chứa bộ truyện này", "check-updates": "Kiểm tra cập nhật", "scan-libraries": "Quét Thư viện", "kavita+-data-refresh": "Tải lại dữ liệu Kavita+", @@ -146,17 +146,17 @@ "stats-permission-denied": "Bạn không được phép xem số liệu thống kê của người dùng khác", "url-not-valid": "Đường dẫn không hiển thị một hình ảnh hợp lệ hoặc yêu cầu xác minh", "url-required": "Bạn cần nhập một đường dẫn để sử dụng", - "on-deck": "Trên kệ sách", + "on-deck": "Đang đọc", "browse-libraries": "Lướt theo Thư viện", "collections": "Tất cả Bộ sưu tập", "smart-filters": "Bộ lọc thông minh", - "report-stats": "Thống kê báo cáo", + "report-stats": "Báo cáo Thống kê", "cleanup": "Dọn sạch", "backup": "Sao lưu", "generic-user-delete": "Không thể xóa người dùng", "opds-disabled": "OPDS chưa được bật trên máy chủ này", - "browse-want-to-read": "Lướt Muốn đọc", - "browse-recently-added": "Lướt Mới thêm vào", + "browse-want-to-read": "Duyệt Sách Muốn Đọc", + "browse-recently-added": "Duyệt Sách Mới thêm vào", "reading-lists": "Danh sách đọc", "libraries": "Tất cả Thư viện", "browse-collections": "Lướt theo Bộ sưu tập", @@ -164,17 +164,42 @@ "recently-updated": "Mới cập nhật", "external-sources": "Nguồn bên ngoài", "browse-external-sources": "Lướt Nguồn bên ngoài", - "smart-filter-already-in-use": "Bộ lọc thông minh này đã được sử dụng", - "external-source-required": "Cần mã API và nhà cung cấp", + "smart-filter-already-in-use": "Có một luồng đã tồn tại với Bộ lọc thông minh này", + "external-source-required": "Cần API Key và Host", "unable-to-register-k+": "Không đăng ký bản quyền này được vì có lỗi. Hãy liên hệ Hỗ trợ Kavita+", "unable-to-reset-k+": "Không đặt lại bản quyền Kavita+ được vì có lỗi. Hãy liên hệ Hỗ trợ Kavita+", - "anilist-cred-expired": "Đăng nhập AniList hết hạn hoặc chưa được cài đặt", + "anilist-cred-expired": "Thông tin xác thực AniList đã hết hạn hoặc chưa được thiết lập", "epub-malformed": "Tập tin bị lỗi! Không đọc được.", - "send-to-permission": "Không thể gửi PDF hay tập tin định dạng khác EPUB đến thiết bị vì Kindle không hỗ trợ", + "send-to-permission": "Không thể gửi tệp tin FDF hoặc định dạng khác EPUB đến Kindle vì thiết bị này không hỗ trợ", "volume-num": "Tập {0}", - "book-num": "Quyển {0}", + "book-num": "Sách {0}", "issue-num": "Kỳ {0}{1}", "license-check": "Kiểm tra bản quyền", "remove-from-want-to-read": "Dọn sạch Muốn đọc", - "update-yearly-stats": "Cập nhật Thống kê hằng năm" + "update-yearly-stats": "Cập nhật Thống kê hằng năm", + "generic-cover-series-save": "Không thể lưu ảnh bìa vào Series", + "external-source-already-in-use": "Có một luồng hiện có với Nguồn bên ngoài này", + "generic-scrobble-hold": "Đã xảy ra lỗi khi thêm lệnh giữ", + "generic-series-delete": "Đã có sự cố khi xóa Series này", + "encode-as-warning": "Bạn không thể chuyển đổi sang PNG. Đối với bìa, hãy sử dụng \"Làm mới Trang Bìa\". Không thể mã hóa lại dấu trang và biểu tượng yêu thích.", + "person-doesnt-exist": "Người dùng không tồn tại", + "series-restricted": "Người dùng không có quyền truy cập vào Series này", + "no-series": "Không thể lấy được series cho Thư viện", + "bad-copy-files-for-download": "Không thể sao chép tệp vào thư mục lưu trữ tạm thời.", + "process-processed-scrobbling-events": "Xử lý sự kiện Scrobbling đã xử lý", + "generic-series-update": "Đã xảy ra lỗi khi cập nhật series", + "dashboard-stream-doesnt-exist": "Bảng điều khiển Stream không tồn tại", + "sidenav-stream-doesnt-exist": "SideNav Stream không tồn tại", + "scrobble-bad-payload": "Bad payload từ Nhà cung cấp Scrobble", + "progress-must-exist": "Tiến trình phải tồn tại trên người dùng", + "generic-create-temp-archive": "Đã xảy ra sự cố khi tạo kho lưu trữ tạm thời", + "process-scrobbling-events": "Xử lý Sự kiện Scrobbling", + "check-scrobbling-tokens": "Kiểm tra Scrobbling Tokens", + "series-doesnt-exist": "Series không tồn tại", + "volume-doesnt-exist": "Tập không tồn tại", + "perform-scan": "Vui lòng thực hiện quét trên Series này hoặc thư viện và thử lại", + "no-series-collection": "Không thể lấy được series cho Collection", + "person-name-required": "Tên người là bắt buộc và không được để trống", + "person-name-unique": "Tên người phải là duy nhất", + "person-image-doesnt-exist": "Người không tồn tại trong CoversDB" } diff --git a/API/I18N/zh_Hans.json b/API/I18N/zh_Hans.json index aea284e5d..14c8c902e 100644 --- a/API/I18N/zh_Hans.json +++ b/API/I18N/zh_Hans.json @@ -179,7 +179,7 @@ "unable-to-reset-k+": "因为一些错误导致无法重置 Kavita+ 许可证。请联系 Kavita+ 支持人员", "email-not-enabled": "此服务器上未启用电子邮件。您无法执行此操作。", "send-to-unallowed": "您无法发送到不属于您的设备", - "send-to-size-limit": "您尝试发送的文件对于您的电子邮件来说太大", + "send-to-size-limit": "您尝试发送的文件对于您的电子邮件提供商来说太大", "process-scrobbling-events": "处理 Scrobbling 事件", "report-stats": "报告统计", "check-updates": "检查更新", @@ -197,5 +197,17 @@ "collection-already-exists": "收藏已存在", "error-import-stack": "导入 MAL stack 时出现问题", "generic-cover-volume-save": "无法将封面图片保存至卷", - "generic-cover-person-save": "无法将封面图片保存到人物" + "generic-cover-person-save": "无法将封面图片保存到人物", + "person-doesnt-exist": "人员不存在", + "person-name-required": "人员姓名为必填项,且不能为空", + "person-name-unique": "人名必须是唯一的", + "person-image-doesnt-exist": "CoversDB 中不存在此人", + "email-taken": "电子邮件已被使用", + "kavitaplus-restricted": "仅限 Kavita+", + "dashboard-stream-only-delete-smart-filter": "只能从仪表板中删除智能筛选器流", + "smart-filter-name-required": "需要智能筛选器名称", + "smart-filter-system-name": "您不能使用系统提供的流名称", + "sidenav-stream-only-delete-smart-filter": "只能从侧边栏删除智能筛选器流", + "aliases-have-overlap": "一个或多个别名与其他人有重叠,无法更新", + "generated-reading-profile-name": "由 {0} 生成" } diff --git a/API/I18N/zh_Hant.json b/API/I18N/zh_Hant.json index a97915a03..31d4b69f6 100644 --- a/API/I18N/zh_Hant.json +++ b/API/I18N/zh_Hant.json @@ -173,7 +173,7 @@ "send-to-unallowed": "您無法傳送到不是您自己的裝置", "email-settings-invalid": "電子郵件設定缺少資訊。請確保所有電子郵件設定已保存。", "collection-already-exists": "組合已存在", - "send-to-size-limit": "您嘗試傳送的文件對於您的電子郵件系統來說過大", + "send-to-size-limit": "您嘗試傳送的文件過大,無法通過您的電子郵件服務提供商發送", "external-sources": "外部來源", "dashboard-stream-doesnt-exist": "儀表板串流不存在", "unable-to-reset-k+": "發生錯誤,無法重置 Kavita+ 授權。請聯繫 Kavita+ 支援", @@ -193,9 +193,21 @@ "update-yearly-stats": "更新年度統計", "invalid-email": "使用者檔案中的電子郵件無效。請查看日誌以獲得任何連結。", "browse-external-sources": "瀏覽外部來源", - "sidenav-stream-doesnt-exist": "側邊導覽串流不存在", + "sidenav-stream-doesnt-exist": "側邊導覽列的串流不存在", "smart-filter-already-in-use": "已存在具有此智慧篩選器的串流", "external-source-already-exists": "外部來源已存在", "generic-cover-volume-save": "無法保存封面圖片", - "generic-cover-person-save": "無法保存封面圖片" + "generic-cover-person-save": "無法保存封面圖片", + "person-doesnt-exist": "此人不存在", + "person-name-required": "名稱為必填欄位,且不得留空", + "person-name-unique": "名稱不得重複", + "person-image-doesnt-exist": "CoversDB 中不存在此人", + "email-taken": "電子郵件已被使用", + "sidenav-stream-only-delete-smart-filter": "只有智慧篩選器可以從側邊導覽列中刪除", + "dashboard-stream-only-delete-smart-filter": "只有智慧篩選器串流可以從儀表板中刪除", + "smart-filter-name-required": "智慧篩選器名稱名稱不可為空", + "smart-filter-system-name": "您不能使用系統保留的串流名稱", + "kavitaplus-restricted": "此功能僅限 Kavita+ 使用", + "aliases-have-overlap": "一個或多個別名與其他人物重複,無法更新", + "generated-reading-profile-name": "由 {0} 生成" } diff --git a/API/Middleware/SecurityMiddleware.cs b/API/Middleware/SecurityMiddleware.cs index 61ca1c75d..67cb42d0c 100644 --- a/API/Middleware/SecurityMiddleware.cs +++ b/API/Middleware/SecurityMiddleware.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Net; using System.Text.Json; using System.Threading.Tasks; @@ -26,7 +27,7 @@ public class SecurityEventMiddleware(RequestDelegate next) } catch (KavitaUnauthenticatedUserException ex) { - var ipAddress = context.Connection.RemoteIpAddress?.ToString(); + var ipAddress = context.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString(); var requestMethod = context.Request.Method; var requestPath = context.Request.Path; var userAgent = context.Request.Headers.UserAgent; diff --git a/API/Program.cs b/API/Program.cs index 925214920..011a7de2a 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,5 +1,5 @@ using System; -using System.Globalization; +using System.IO; using System.IO.Abstractions; using System.Linq; using System.Security.Cryptography; @@ -21,9 +21,11 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using NetVips; using Serilog; using Serilog.Events; using Serilog.Sinks.AspNetCore.SignalR.Extensions; +using Log = Serilog.Log; namespace API; #nullable enable @@ -38,9 +40,6 @@ public class Program public static async Task Main(string[] args) { - CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; - CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; - Console.OutputEncoding = System.Text.Encoding.UTF8; Log.Logger = new LoggerConfiguration() .WriteTo.Console() @@ -50,18 +49,13 @@ public class Program var directoryService = new DirectoryService(null!, new FileSystem()); - // Before anything, check if JWT has been generated properly or if user still has default - if (!Configuration.CheckIfJwtTokenSet() && - Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development) - { - Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions..."); - var rBytes = new byte[256]; - RandomNumberGenerator.Create().GetBytes(rBytes); - Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); - } - Configuration.KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development - ? "http://localhost:5020" : "https://plus.kavitareader.com"; + // Check if this is the first time running and if so, rename appsettings-init.json to appsettings.json + HandleFirstRunConfiguration(); + + + // Before anything, check if JWT has been generated properly or if user still has default + EnsureJwtTokenKey(); try { @@ -75,6 +69,7 @@ public class Program { var logger = services.GetRequiredService>(); var context = services.GetRequiredService(); + var pendingMigrations = await context.Database.GetPendingMigrationsAsync(); var isDbCreated = await context.Database.CanConnectAsync(); if (isDbCreated && pendingMigrations.Any()) @@ -106,6 +101,9 @@ public class Program // v0.8.2 await ManualMigrateSwitchToWal.Migrate(context, logger); + + // v0.8.4 + await ManualMigrateEncodeSettings.Migrate(context, logger); } catch (Exception ex) { @@ -129,6 +127,7 @@ public class Program await Seed.SeedDefaultStreams(unitOfWork); await Seed.SeedDefaultSideNavStreams(unitOfWork); await Seed.SeedUserApiKeys(context); + await Seed.SeedMetadataSettings(context); } catch (Exception ex) { @@ -146,6 +145,8 @@ public class Program var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); LogLevelOptions.SwitchLogLevel(settings.LoggingLevel); + InitNetVips(); + await host.RunAsync(); } catch (Exception ex) { @@ -156,6 +157,26 @@ public class Program } } + private static void EnsureJwtTokenKey() + { + if (Configuration.CheckIfJwtTokenSet() || Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) return; + + Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions..."); + var rBytes = new byte[256]; + RandomNumberGenerator.Create().GetBytes(rBytes); + Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); + } + + private static void HandleFirstRunConfiguration() + { + var firstRunConfigFilePath = Path.Join(Directory.GetCurrentDirectory(), "config/appsettings-init.json"); + if (File.Exists(firstRunConfigFilePath) && + !File.Exists(Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json"))) + { + File.Move(firstRunConfigFilePath, Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json")); + } + } + private static async Task GetMigrationDirectory(DataContext context, IDirectoryService directoryService) { string? currentVersion = null; @@ -228,4 +249,14 @@ public class Program webBuilder.UseStartup(); }); + + /// + /// Ensure NetVips does not cache + /// + /// https://github.com/kleisauke/net-vips/issues/6#issuecomment-394379299 + private static void InitNetVips() + { + Cache.MaxFiles = 0; + + } } diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 71dc0f3b6..74b6709fa 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -5,8 +5,10 @@ using System.Threading.Tasks; using System.Web; using API.Constants; using API.Data; +using API.DTOs.Account; using API.Entities; using API.Errors; +using API.Extensions; using Kavita.Common; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -46,7 +48,7 @@ public class AccountService : IAccountService public async Task> ChangeUserPassword(AppUser user, string newPassword) { var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList(); - if (passwordValidationIssues.Any()) return passwordValidationIssues; + if (passwordValidationIssues.Count != 0) return passwordValidationIssues; var result = await _userManager.RemovePasswordAsync(user); if (!result.Succeeded) @@ -55,15 +57,11 @@ public class AccountService : IAccountService return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } - result = await _userManager.AddPasswordAsync(user, newPassword); - if (!result.Succeeded) - { - _logger.LogError("Could not update password"); - return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); - } + if (result.Succeeded) return []; - return new List(); + _logger.LogError("Could not update password"); + return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } public async Task> ValidatePassword(AppUser user, string password) @@ -81,26 +79,28 @@ public class AccountService : IAccountService } public async Task> ValidateUsername(string username) { - if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) + // Reverted because of https://go.microsoft.com/fwlink/?linkid=2129535 + if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName != null + && x.NormalizedUserName == username.ToUpper())) { - return new List() - { - new ApiException(400, "Username is already taken") - }; + return + [ + new(400, "Username is already taken") + ]; } - return Array.Empty(); + return []; } public async Task> ValidateEmail(string email) { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); - if (user == null) return Array.Empty(); + if (user == null) return []; - return new List() - { + return + [ new ApiException(400, "Email is already registered") - }; + ]; } /// @@ -112,6 +112,7 @@ public class AccountService : IAccountService { if (user == null) return false; var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.BookmarkRole) || roles.Contains(PolicyConstants.AdminRole); } @@ -124,6 +125,7 @@ public class AccountService : IAccountService { if (user == null) return false; var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole); } @@ -135,9 +137,10 @@ public class AccountService : IAccountService public async Task CanChangeAgeRestriction(AppUser? user) { if (user == null) return false; + var roles = await _userManager.GetRolesAsync(user); if (roles.Contains(PolicyConstants.ReadOnlyRole)) return false; + return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole); } - } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 120cbf3f7..335a5a74b 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -16,6 +16,7 @@ using Kavita.Common; using Microsoft.Extensions.Logging; using SharpCompress.Archives; using SharpCompress.Common; +using YamlDotNet.Core; namespace API.Services; @@ -127,9 +128,11 @@ public class ArchiveService : IArchiveService } case ArchiveLibrary.NotSupported: _logger.LogWarning("[GetNumberOfPagesFromArchive] This archive cannot be read: {ArchivePath}. Defaulting to 0 pages", archivePath); + _mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService, "File format not supported", string.Empty); return 0; default: _logger.LogWarning("[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); + _mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService, "File format not supported", string.Empty); return 0; } } @@ -352,16 +355,23 @@ public class ArchiveService : IArchiveService foreach (var path in files) { var tempPath = Path.Join(tempLocation, _directoryService.FileSystem.Path.GetFileNameWithoutExtension(_directoryService.FileSystem.FileInfo.New(path).Name)); - progressCallback(Tuple.Create(_directoryService.FileSystem.FileInfo.New(path).Name, (1.0f * totalFiles) / count)); - if (Tasks.Scanner.Parser.Parser.IsArchive(path)) + + // Image series need different handling + if (Tasks.Scanner.Parser.Parser.IsImage(path)) { - ExtractArchive(path, tempPath); - } - else - { - _directoryService.CopyFileToDirectory(path, tempPath); + var parentDirectory = _directoryService.FileSystem.DirectoryInfo.New(path).Parent?.Name; + tempPath = Path.Join(tempLocation, parentDirectory ?? _directoryService.FileSystem.FileInfo.New(path).Name); } + if (Tasks.Scanner.Parser.Parser.IsArchive(path)) + { + // Archives don't need to be put into a subdirectory of the same name + tempPath = _directoryService.GetParentDirectoryName(tempPath); + } + + progressCallback(Tuple.Create(_directoryService.FileSystem.FileInfo.New(path).Name, (1.0f * totalFiles) / count)); + + _directoryService.CopyFileToDirectory(path, tempPath); count++; } } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index e4ed92047..99fdd1400 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -6,12 +6,14 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Xml; using API.Data.Metadata; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Services.Tasks.Scanner.Parser; +using API.Helpers; using Docnet.Core; using Docnet.Core.Converters; using Docnet.Core.Models; @@ -69,12 +71,50 @@ public class BookService : IBookService private static readonly RecyclableMemoryStreamManager StreamManager = new (); private const string CssScopeClass = ".book-content"; private const string BookApiUrl = "book-resources?file="; + private readonly PdfComicInfoExtractor _pdfComicInfoExtractor; + + /// + /// Setup the most lenient book parsing options possible as people have some really bad epubs + /// public static readonly EpubReaderOptions BookReaderOptions = new() { PackageReaderOptions = new PackageReaderOptions { IgnoreMissingToc = true, - SkipInvalidManifestItems = true + SkipInvalidManifestItems = true, + }, + Epub2NcxReaderOptions = new Epub2NcxReaderOptions + { + IgnoreMissingContentForNavigationPoints = false + }, + SpineReaderOptions = new SpineReaderOptions + { + IgnoreMissingManifestItems = false + }, + BookCoverReaderOptions = new BookCoverReaderOptions + { + Epub2MetadataIgnoreMissingManifestItem = false + } + }; + + public static readonly EpubReaderOptions LenientBookReaderOptions = new() + { + PackageReaderOptions = new PackageReaderOptions + { + IgnoreMissingToc = true, + SkipInvalidManifestItems = true, + }, + Epub2NcxReaderOptions = new Epub2NcxReaderOptions + { + IgnoreMissingContentForNavigationPoints = false + }, + SpineReaderOptions = new SpineReaderOptions + { + IgnoreMissingManifestItems = false + }, + BookCoverReaderOptions = new BookCoverReaderOptions + { + Epub2MetadataIgnoreMissingManifestItem = true } }; @@ -84,6 +124,7 @@ public class BookService : IBookService _directoryService = directoryService; _imageService = imageService; _mediaErrorService = mediaErrorService; + _pdfComicInfoExtractor = new PdfComicInfoExtractor(_logger, _mediaErrorService); } private static bool HasClickableHrefPart(HtmlNode anchor) @@ -306,8 +347,16 @@ public class BookService : IBookService var imageFile = GetKeyForImage(book, image.Attributes[key].Value); image.Attributes.Remove(key); - // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx - image.Attributes.Add(key, $"{apiBase}" + Uri.EscapeDataString(imageFile)); + + if (!imageFile.StartsWith("http")) + { + // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx + image.Attributes.Add(key, $"{apiBase}" + Uri.EscapeDataString(imageFile)); + } + else + { + image.Attributes.Add(key, imageFile); + } // Add a custom class that the reader uses to ensure images stay within reader parent.AddClass("kavita-scale-width-container"); @@ -350,11 +399,14 @@ public class BookService : IBookService { // Check if any classes on the html node (some r2l books do this) and move them to body tag for scoping var htmlNode = doc.DocumentNode.SelectSingleNode("//html"); - if (htmlNode == null || !htmlNode.Attributes.Contains("class")) return body.InnerHtml; + if (htmlNode == null) return body.InnerHtml; var bodyClasses = body.Attributes.Contains("class") ? body.Attributes["class"].Value : string.Empty; - var classes = htmlNode.Attributes["class"].Value + " " + bodyClasses; - body.Attributes.Add("class", $"{classes}"); + var htmlClasses = htmlNode.Attributes.Contains("class") ? htmlNode.Attributes["class"].Value : string.Empty; + + body.Attributes.Add("class", $"{htmlClasses} {bodyClasses}"); + + // I actually need the body tag itself for the classes, so i will create a div and put the body stuff there. return $"
{body.InnerHtml}
"; } @@ -425,13 +477,14 @@ public class BookService : IBookService } } - public ComicInfo? GetComicInfo(string filePath) + private ComicInfo? GetEpubComicInfo(string filePath) { - if (!IsValidFile(filePath) || Parser.IsPdf(filePath)) return null; + EpubBookRef? epubBook = null; try { - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + epubBook = OpenEpubWithFallback(filePath, epubBook); + var publicationDate = epubBook.Schema.Package.Metadata.Dates.Find(pDate => pDate.Event == "publication")?.Date; @@ -439,10 +492,11 @@ public class BookService : IBookService { publicationDate = epubBook.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date; } + var (year, month, day) = GetPublicationDate(publicationDate); var summary = epubBook.Schema.Package.Metadata.Descriptions.FirstOrDefault(); - var info = new ComicInfo + var info = new ComicInfo { Summary = string.IsNullOrEmpty(summary?.Description) ? string.Empty : summary.Description, Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers.Select(p => p.Publisher)), @@ -450,7 +504,8 @@ public class BookService : IBookService Day = day, Year = year, Title = epubBook.Title, - Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())), + Genre = string.Join(",", + epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())), LanguageISO = ValidateLanguage(epubBook.Schema.Package.Metadata.Languages .Select(l => l.Language) .FirstOrDefault()) @@ -461,7 +516,8 @@ public class BookService : IBookService foreach (var identifier in epubBook.Schema.Package.Metadata.Identifiers) { if (string.IsNullOrEmpty(identifier.Identifier)) continue; - if (!string.IsNullOrEmpty(identifier.Scheme) && identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase)) + if (!string.IsNullOrEmpty(identifier.Scheme) && + identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase)) { var isbn = identifier.Identifier.Replace("urn:isbn:", string.Empty).Replace("isbn:", string.Empty); if (!ArticleNumberHelper.IsValidIsbn10(isbn) && !ArticleNumberHelper.IsValidIsbn13(isbn)) @@ -469,11 +525,13 @@ public class BookService : IBookService _logger.LogDebug("[BookService] {File} has invalid ISBN number", filePath); continue; } + info.Isbn = isbn; } - if ((!string.IsNullOrEmpty(identifier.Scheme) && identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) || - identifier.Identifier.StartsWith("url:")) + if ((!string.IsNullOrEmpty(identifier.Scheme) && + identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) || + identifier.Identifier.StartsWith("url:")) { var url = identifier.Identifier.Replace("url:", string.Empty); weblinks.Add(url.Trim()); @@ -503,6 +561,7 @@ public class BookService : IBookService { info.SeriesSort = metadataItem.Content; } + break; case "calibre:series_index": info.Volume = metadataItem.Content; @@ -522,6 +581,7 @@ public class BookService : IBookService { info.SeriesSort = metadataItem.Content; } + break; case "collection-type": // These look to be genres from https://manual.calibre-ebook.com/sub_groups.html or can be "series" @@ -552,7 +612,8 @@ public class BookService : IBookService } // If this is a single book and not a collection, set publication status to Completed - if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) + if (string.IsNullOrEmpty(info.Volume) && + Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) { info.Count = 1; } @@ -564,7 +625,8 @@ public class BookService : IBookService var hasVolumeInSeries = !Parser.ParseVolume(info.Title, LibraryType.Manga) .Equals(Parser.LooseLeafVolume); - if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) + if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && + (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) { // This is likely a light novel for which we can set series from parsed title info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga); @@ -575,14 +637,54 @@ public class BookService : IBookService } catch (Exception ex) { - _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata"); + _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata: {FilePath}", filePath); _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, "There was an exception parsing metadata", ex); } + finally + { + epubBook?.Dispose(); + } return null; } + private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook) + { + try + { + epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "[GetComicInfo] There was an exception parsing metadata, falling back to a more lenient parsing method: {FilePath}", + filePath); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception parsing metadata", ex); + } + finally + { + epubBook ??= EpubReader.OpenBook(filePath, LenientBookReaderOptions); + } + + return epubBook; + } + + public ComicInfo? GetComicInfo(string filePath) + { + if (!IsValidFile(filePath)) return null; + + if (Parser.IsPdf(filePath)) + { + return _pdfComicInfoExtractor.GetComicInfo(filePath); + } + else + { + return GetEpubComicInfo(filePath); + } + } + private static void ExtractSortTitle(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info) { var titleId = metadataItem.Refines?.Replace("#", string.Empty); @@ -670,7 +772,7 @@ public class BookService : IBookService var month = 0; var day = 0; if (string.IsNullOrEmpty(publicationDate)) return (year, month, day); - switch (DateTime.TryParse(publicationDate, out var date)) + switch (DateTime.TryParse(publicationDate, CultureInfo.InvariantCulture, out var date)) { case true: year = date.Year; @@ -685,7 +787,7 @@ public class BookService : IBookService return (year, month, day); } - private static string ValidateLanguage(string? language) + public static string ValidateLanguage(string? language) { if (string.IsNullOrEmpty(language)) return string.Empty; @@ -725,7 +827,7 @@ public class BookService : IBookService return docReader.GetPageCount(); } - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions); return epubBook.GetReadingOrder().Count; } catch (Exception ex) @@ -783,7 +885,7 @@ public class BookService : IBookService try { - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions); // // @@ -885,7 +987,7 @@ public class BookService : IBookService } /// - /// Extracts a pdf into images to a target directory. Uses multi-threaded implementation since docnet is slow normally. + /// Extracts a pdf into images to a target directory. Uses multithreaded implementation since docnet is slow normally. /// /// /// @@ -987,7 +1089,7 @@ public class BookService : IBookService /// public async Task> GenerateTableOfContents(Chapter chapter) { - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); var navItems = await book.GetNavigationAsync(); @@ -1115,7 +1217,7 @@ public class BookService : IBookService /// All exceptions throw this public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) { - using var book = await EpubReader.OpenBookAsync(cachedEpubPath, BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; @@ -1217,7 +1319,7 @@ public class BookService : IBookService return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat, size); } - using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(fileFilePath, LenientBookReaderOptions); try { diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 4f5dadfe0..283d4b1ac 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -80,7 +80,6 @@ public class CacheService : ICacheService /// public IEnumerable GetCachedFileDimensions(string cachePath) { - var sw = Stopwatch.StartNew(); var files = _directoryService.GetFilesWithExtension(cachePath, Tasks.Scanner.Parser.Parser.ImageFileExtensions) .OrderByNatural(Path.GetFileNameWithoutExtension) .ToArray(); @@ -118,8 +117,6 @@ public class CacheService : ICacheService Cache.MaxFiles = originalCacheSize; } - _logger.LogDebug("File Dimensions call for {Length} images took {Time}ms", dimensions.Count, sw.ElapsedMilliseconds); - return dimensions; } @@ -171,11 +168,34 @@ public class CacheService : ICacheService var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); var extractPath = GetCachePath(chapterId); - SemaphoreSlim extractLock = ExtractLocks.GetOrAdd(chapterId, id => new SemaphoreSlim(1,1)); + var extractLock = ExtractLocks.GetOrAdd(chapterId, id => new SemaphoreSlim(1,1)); await extractLock.WaitAsync(); try { - if(_directoryService.Exists(extractPath)) return chapter; + if (_directoryService.Exists(extractPath)) + { + if (extractPdfToImages) + { + var pdfImages = _directoryService.GetFiles(extractPath, + Tasks.Scanner.Parser.Parser.ImageFileExtensions); + if (pdfImages.Any()) + { + return chapter; + } + } + else + { + // Do an explicit check for files since rarely a "permission denied" error on deleting + // the file can occur, thus leaving an empty folder and we would never re-cache the files. + if (_directoryService.GetFiles(extractPath).Any()) + { + return chapter; + } + + // Delete the extractPath as ExtractArchive will return if the directory already exists + _directoryService.ClearAndDeleteDirectory(extractPath); + } + } var files = chapter?.Files.ToList(); ExtractChapterFiles(extractPath, files, extractPdfToImages); @@ -196,13 +216,13 @@ public class CacheService : ICacheService /// public void ExtractChapterFiles(string extractPath, IReadOnlyList? files, bool extractPdfImages = false) { - if (files == null) return; + if (files == null || files.Count == 0) return; var removeNonImages = true; var fileCount = files.Count; var extraPath = string.Empty; var extractDi = _directoryService.FileSystem.DirectoryInfo.New(extractPath); - if (files.Count > 0 && files[0].Format == MangaFormat.Image) + if (files[0].Format == MangaFormat.Image) { // Check if all the files are Images. If so, do a directory copy, else do the normal copy if (files.All(f => f.Format == MangaFormat.Image)) diff --git a/API/Services/CollectionTagService.cs b/API/Services/CollectionTagService.cs index 645cffcfa..a73c0cea2 100644 --- a/API/Services/CollectionTagService.cs +++ b/API/Services/CollectionTagService.cs @@ -58,7 +58,7 @@ public class CollectionTagService : ICollectionTagService if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, userId)) throw new KavitaException("collection-tag-duplicate"); - existingTag.Items ??= new List(); + existingTag.Items ??= []; if (existingTag.Source == ScrobbleProvider.Kavita) { existingTag.Title = title; @@ -74,7 +74,7 @@ public class CollectionTagService : ICollectionTagService _unitOfWork.CollectionTagRepository.Update(existingTag); // Check if Tag has updated (Summary) - var summary = dto.Summary.Trim(); + var summary = (dto.Summary ?? string.Empty).Trim(); if (existingTag.Summary == null || !existingTag.Summary.Equals(summary)) { existingTag.Summary = summary; @@ -105,7 +105,7 @@ public class CollectionTagService : ICollectionTagService { if (tag == null) return false; - tag.Items ??= new List(); + tag.Items ??= []; tag.Items = tag.Items.Where(s => !seriesIds.Contains(s.Id)).ToList(); if (tag.Items.Count == 0) diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 1c56c65fc..7e308d92e 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -31,10 +31,18 @@ public interface IDirectoryService string TemplateDirectory { get; } string PublisherDirectory { get; } /// + /// Used for caching documents that may need to stay on disk for more than a day + /// + string LongTermCacheDirectory { get; } + /// /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// string BookmarkDirectory { get; } /// + /// Used for random files needed, like images to check against, list of countries, etc + /// + string AssetsDirectory { get; } + /// /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. /// /// Absolute path of directory to scan. @@ -61,6 +69,7 @@ public interface IDirectoryService IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly); bool ExistOrCreate(string directoryPath); void DeleteFiles(IEnumerable files); + void CopyFile(string sourcePath, string destinationPath, bool overwrite = true); void RemoveNonImages(string directoryName); void Flatten(string directoryName); Task CheckWriteAccess(string directoryName); @@ -83,12 +92,14 @@ public class DirectoryService : IDirectoryService public string TempDirectory { get; } public string ConfigDirectory { get; } public string BookmarkDirectory { get; } + public string AssetsDirectory { get; } public string SiteThemeDirectory { get; } public string FaviconDirectory { get; } public string LocalizationDirectory { get; } public string CustomizedTemplateDirectory { get; } public string TemplateDirectory { get; } public string PublisherDirectory { get; } + public string LongTermCacheDirectory { get; } private readonly ILogger _logger; private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; @@ -115,6 +126,8 @@ public class DirectoryService : IDirectoryService ExistOrCreate(TempDirectory); BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); ExistOrCreate(BookmarkDirectory); + AssetsDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "Assets"); + ExistOrCreate(AssetsDirectory); SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); ExistOrCreate(SiteThemeDirectory); FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons"); @@ -126,6 +139,8 @@ public class DirectoryService : IDirectoryService ExistOrCreate(TemplateDirectory); PublisherDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "images", "publishers"); ExistOrCreate(PublisherDirectory); + LongTermCacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache-long"); + ExistOrCreate(LongTermCacheDirectory); } /// @@ -708,7 +723,7 @@ public class DirectoryService : IDirectoryService if (!FileSystem.Directory.Exists(folderPath)) return ImmutableArray.Empty; var directories = new List(); - var foundDirs = GetDirectories(folderPath); + var foundDirs = GetDirectories(folderPath, matcher); foreach (var foundDir in foundDirs) { directories.Add(foundDir); @@ -746,7 +761,6 @@ public class DirectoryService : IDirectoryService public IList ScanFiles(string folderPath, string fileTypes, GlobMatcher? matcher = null, SearchOption searchOption = SearchOption.AllDirectories) { - _logger.LogTrace("[ScanFiles] called on {Path}", folderPath); var files = new List(); if (!Exists(folderPath)) return files; @@ -924,6 +938,27 @@ public class DirectoryService : IDirectoryService } } + public void CopyFile(string sourcePath, string destinationPath, bool overwrite = true) + { + if (!File.Exists(sourcePath)) + { + throw new FileNotFoundException("Source file not found", sourcePath); + } + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrEmpty(destinationDirectory)) + { + throw new ArgumentException("Destination path does not contain a directory", nameof(destinationPath)); + } + + if (!Directory.Exists(destinationDirectory)) + { + FileSystem.Directory.CreateDirectory(destinationDirectory); + } + + FileSystem.File.Copy(sourcePath, destinationPath, overwrite); + } + /// /// Returns the human-readable file size for an arbitrary, 64-bit file size /// The default format is "0.## XB", e.g. "4.2 KB" or "1.43 GB" @@ -1077,4 +1112,23 @@ public class DirectoryService : IDirectoryService FlattenDirectory(root, subDirectory, ref directoryIndex); } } + + /// + /// If the file is locked or not existing + /// + /// + /// + public static bool IsFileLocked(string filePath) + { + try + { + if (!File.Exists(filePath)) return false; + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None); + return false; // If this works, the file is not locked + } + catch (IOException) + { + return true; // File is locked by another process + } + } } diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 0a8ba6404..35cfa7b04 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -4,16 +4,22 @@ using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Net; +using System.Text; using System.Threading.Tasks; using System.Web; using API.Data; using API.DTOs.Email; +using API.Entities; +using API.Services.Plus; using Kavita.Common; +using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Extensions; using MailKit.Security; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using MimeKit; +using MimeTypes; namespace API.Services; #nullable enable @@ -29,6 +35,8 @@ internal class EmailOptionsDto /// Filenames to attach /// public IList? Attachments { get; set; } + public int? ToUserId { get; set; } + public required string Template { get; set; } } public interface IEmailService @@ -43,6 +51,10 @@ public interface IEmailService Task GenerateEmailLink(HttpRequest request, string token, string routePart, string email, bool withHost = true); + + Task SendTokenExpiredEmail(int userId, ScrobbleProvider provider); + Task SendTokenExpiringSoonEmail(int userId, ScrobbleProvider provider); + Task SendKavitaPlusDebug(); } public class EmailService : IEmailService @@ -56,6 +68,15 @@ public class EmailService : IEmailService private const string TemplatePath = @"{0}.html"; private const string LocalHost = "localhost:4200"; + public const string SendToDeviceTemplate = "SendToDevice"; + public const string EmailTestTemplate = "EmailTest"; + public const string EmailChangeTemplate = "EmailChange"; + public const string TokenExpirationTemplate = "TokenExpiration"; + public const string TokenExpiringSoonTemplate = "TokenExpiringSoon"; + public const string EmailConfirmTemplate = "EmailConfirm"; + public const string EmailPasswordResetTemplate = "EmailPasswordReset"; + public const string KavitaPlusDebugTemplate = "KavitaPlusDebug"; + public EmailService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, IHostEnvironment environment, ILocalizationService localizationService) { @@ -104,12 +125,13 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = "Kavita - Email Test", - Body = UpdatePlaceHolders(await GetEmailBody("EmailTest"), placeholders), + Template = EmailTestTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailTestTemplate), placeholders), Preheader = "Kavita - Email Test", ToEmails = new List() { adminEmail - } + }, }; await SendEmail(emailOptions); @@ -139,7 +161,8 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("Your email has been changed on {{InvitingUser}}'s Server", placeholders), - Body = UpdatePlaceHolders(await GetEmailBody("EmailChange"), placeholders), + Template = EmailChangeTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailChangeTemplate), placeholders), Preheader = UpdatePlaceHolders("Your email has been changed on {{InvitingUser}}'s Server", placeholders), ToEmails = new List() { @@ -155,9 +178,9 @@ public class EmailService : IEmailService /// /// /// - public bool IsValidEmail(string email) + public bool IsValidEmail(string? email) { - return new EmailAddressAttribute().IsValid(email); + return !string.IsNullOrEmpty(email) && new EmailAddressAttribute().IsValid(email); } public async Task GenerateEmailLink(HttpRequest request, string token, string routePart, string email, bool withHost = true) @@ -180,6 +203,100 @@ public class EmailService : IEmailService .Replace("//", "/"); } + public async Task SendTokenExpiredEmail(int userId, ScrobbleProvider provider) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (user == null || !IsValidEmail(user.Email) || !settings.IsEmailSetup()) return false; + + var placeholders = new List> + { + new ("{{UserName}}", user.UserName!), + new ("{{Provider}}", provider.ToDescription()), + new ("{{Link}}", $"{settings.HostName}/settings#account" ), + }; + + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("Kavita - Your {{Provider}} token has expired and scrobbling events have stopped", placeholders), + Template = TokenExpirationTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(TokenExpirationTemplate), placeholders), + Preheader = UpdatePlaceHolders("Kavita - Your {{Provider}} token has expired and scrobbling events have stopped", placeholders), + ToEmails = new List() + { + user.Email + } + }; + + await SendEmail(emailOptions); + + return true; + } + + public async Task SendTokenExpiringSoonEmail(int userId, ScrobbleProvider provider) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (user == null || !IsValidEmail(user.Email) || !settings.IsEmailSetup()) return false; + + var placeholders = new List> + { + new ("{{UserName}}", user.UserName!), + new ("{{Provider}}", provider.ToDescription()), + new ("{{Link}}", $"{settings.HostName}/settings#account" ), + }; + + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("Kavita - Your {{Provider}} token will expire soon!", placeholders), + Template = TokenExpiringSoonTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(TokenExpiringSoonTemplate), placeholders), + Preheader = UpdatePlaceHolders("Kavita - Your {{Provider}} token will expire soon!", placeholders), + ToEmails = new List() + { + user.Email + } + }; + + await SendEmail(emailOptions); + + return true; + } + + /// + /// Sends information about Kavita install for Kavita+ registration + /// + /// Users in China can have issues subscribing, this flow will allow me to register their instance on their behalf + /// + public async Task SendKavitaPlusDebug() + { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!settings.IsEmailSetup()) return false; + + var placeholders = new List> + { + new ("{{InstallId}}", HashUtil.ServerToken()), + new ("{{Build}}", BuildInfo.Version.ToString()), + }; + + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("Kavita+: A User needs manual registration", placeholders), + Template = KavitaPlusDebugTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(KavitaPlusDebugTemplate), placeholders), + Preheader = UpdatePlaceHolders("Kavita+: A User needs manual registration", placeholders), + ToEmails = + [ + // My kavita email + Encoding.UTF8.GetString(Convert.FromBase64String("a2F2aXRhcmVhZGVyQGdtYWlsLmNvbQ==")) + ] + }; + + await SendEmail(emailOptions); + + return true; + } + /// /// Sends an invite email to a user to setup their account /// @@ -195,7 +312,8 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Server", placeholders), - Body = UpdatePlaceHolders(await GetEmailBody("EmailConfirm"), placeholders), + Template = EmailConfirmTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailConfirmTemplate), placeholders), Preheader = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Server", placeholders), ToEmails = new List() { @@ -221,12 +339,13 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("A password reset has been requested", placeholders), - Body = UpdatePlaceHolders(await GetEmailBody("EmailPasswordReset"), placeholders), - Preheader = "A password reset has been requested", - ToEmails = new List() - { + Template = EmailPasswordResetTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailPasswordResetTemplate), placeholders), + Preheader = "Email confirmation is required for continued access. Click the button to confirm your email.", + ToEmails = + [ dto.EmailAddress - } + ] }; await SendEmail(emailOptions); @@ -242,11 +361,9 @@ public class EmailService : IEmailService { Subject = "Send file from Kavita", Preheader = "File(s) sent from Kavita", - ToEmails = new List() - { - data.DestinationEmail - }, - Body = await GetEmailBody("SendToDevice"), + ToEmails = [data.DestinationEmail], + Template = SendToDeviceTemplate, + Body = await GetEmailBody(SendToDeviceTemplate), Attachments = data.FilePaths.ToList() }; @@ -277,9 +394,21 @@ public class EmailService : IEmailService if (userEmailOptions.Attachments != null) { - foreach (var attachment in userEmailOptions.Attachments) + foreach (var attachmentPath in userEmailOptions.Attachments) { - await body.Attachments.AddAsync(attachment); + var mimeType = MimeTypeMap.GetMimeType(attachmentPath) ?? "application/octet-stream"; + var mediaType = mimeType.Split('/')[0]; + var mediaSubtype = mimeType.Split('/')[1]; + + var attachment = new MimePart(mediaType, mediaSubtype) + { + Content = new MimeContent(File.OpenRead(attachmentPath)), + ContentDisposition = new ContentDisposition(ContentDisposition.Attachment), + ContentTransferEncoding = ContentEncoding.Base64, + FileName = Path.GetFileName(attachmentPath) + }; + + body.Attachments.Add(attachment); } } @@ -302,21 +431,66 @@ public class EmailService : IEmailService ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; + var emailAddress = userEmailOptions.ToEmails[0]; + AppUser? user; + if (userEmailOptions.Template == SendToDeviceTemplate) + { + user = await _unitOfWork.UserRepository.GetUserByDeviceEmail(emailAddress); + } + else + { + user = await _unitOfWork.UserRepository.GetUserByEmailAsync(emailAddress); + } + + try { await smtpClient.SendAsync(email); + if (user != null) + { + await LogEmailHistory(user.Id, userEmailOptions.Template, userEmailOptions.Subject, userEmailOptions.Body, "Sent"); + } } catch (Exception ex) { _logger.LogError(ex, "There was an issue sending the email"); + + if (user != null) + { + await LogEmailHistory(user.Id, userEmailOptions.Template, userEmailOptions.Subject, userEmailOptions.Body, "Failed", ex.Message); + } + _logger.LogError("Could not find user on file for email, {Template} email was not sent and not recorded into history table", userEmailOptions.Template); + throw; } finally { await smtpClient.DisconnectAsync(true); + } } + /// + /// Logs email history for the specified user. + /// + private async Task LogEmailHistory(int appUserId, string emailTemplate, string subject, string body, string deliveryStatus, string? errorMessage = null) + { + var emailHistory = new EmailHistory + { + AppUserId = appUserId, + EmailTemplate = emailTemplate, + Sent = deliveryStatus == "Sent", + Body = body, + Subject = subject, + SendDate = DateTime.UtcNow, + DeliveryStatus = deliveryStatus, + ErrorMessage = errorMessage + }; + + _unitOfWork.DataContext.EmailHistory.Add(emailHistory); + await _unitOfWork.CommitAsync(); + } + private async Task GetTemplatePath(string templateName) { if ((await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).SmtpConfig.CustomizedTemplates) diff --git a/API/Services/FileService.cs b/API/Services/FileService.cs index 37222655a..2cb34c601 100644 --- a/API/Services/FileService.cs +++ b/API/Services/FileService.cs @@ -70,7 +70,6 @@ public class FileService : IFileService // Compute SHA hash var checksum = SHA256.HashData(Encoding.UTF8.GetBytes(content)); - return BitConverter.ToString(checksum).Replace("-", string.Empty).Equals(sha); - + return Convert.ToHexString(checksum).Equals(sha); } } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 3236acdf9..544efa4ce 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -1,20 +1,13 @@ using System; using System.Collections.Generic; -using System.Drawing; using System.IO; using System.Linq; using System.Numerics; using System.Threading.Tasks; -using API.Constants; using API.DTOs; using API.Entities.Enums; using API.Entities.Interfaces; using API.Extensions; -using EasyCaching.Core; -using Flurl; -using Flurl.Http; -using HtmlAgilityPack; -using Kavita.Common; using Microsoft.Extensions.Logging; using NetVips; using SixLabors.ImageSharp.PixelFormats; @@ -57,6 +50,7 @@ public interface IImageService /// /// string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); + /// /// Converts the passed image to encoding and outputs it in the same directory /// @@ -66,17 +60,15 @@ public interface IImageService /// File of written encoded image Task ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat); Task IsImage(string filePath); - Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); - Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); void UpdateColorScape(IHasCoverImage entity); } public class ImageService : IImageService { - public const string Name = "BookmarkService"; + public const string Name = "ImageService"; private readonly ILogger _logger; private readonly IDirectoryService _directoryService; - private readonly IEasyCachingProviderFactory _cacheFactory; + public const string ChapterCoverImageRegex = @"v\d+_c\d+"; public const string SeriesCoverImageRegex = @"series\d+"; public const string CollectionTagCoverImageRegex = @"tag\d+"; @@ -100,26 +92,10 @@ public class ImageService : IImageService public const int LibraryThumbnailWidth = 32; - private static readonly string[] ValidIconRelations = { - "icon", - "apple-touch-icon", - "apple-touch-icon-precomposed", - "apple-touch-icon icon-precomposed" // ComicVine has it combined - }; - - /// - /// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon) - /// - private static readonly IDictionary FaviconUrlMapper = new Dictionary - { - ["https://app.plex.tv"] = "https://plex.tv" - }; - - public ImageService(ILogger logger, IDirectoryService directoryService, IEasyCachingProviderFactory cacheFactory) + public ImageService(ILogger logger, IDirectoryService directoryService) { _logger = logger; _directoryService = directoryService; - _cacheFactory = cacheFactory; } public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1) @@ -335,151 +311,8 @@ public class ImageService : IImageService return false; } - public async Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat) - { - // Parse the URL to get the domain (including subdomain) - var uri = new Uri(url); - var domain = uri.Host.Replace(Environment.NewLine, string.Empty); - var baseUrl = uri.Scheme + "://" + uri.Host; - var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon); - var res = await provider.GetAsync(baseUrl); - if (res.HasValue) - { - _logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", baseUrl); - throw new KavitaException($"Kavita has already tried to fetch from {baseUrl} and failed. Skipping duplicate check"); - } - - await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10)); - if (FaviconUrlMapper.TryGetValue(baseUrl, out var value)) - { - url = value; - } - - var correctSizeLink = string.Empty; - - try - { - var htmlContent = url.GetStringAsync().Result; - var htmlDocument = new HtmlDocument(); - htmlDocument.LoadHtml(htmlContent); - var pngLinks = htmlDocument.DocumentNode.Descendants("link") - .Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty))) - .Select(link => link.GetAttributeValue("href", string.Empty)) - .Where(href => href.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase)) - .ToList(); - - correctSizeLink = (pngLinks?.Find(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain); - } - - try - { - if (string.IsNullOrEmpty(correctSizeLink)) - { - correctSizeLink = await FallbackToKavitaReaderFavicon(baseUrl); - } - if (string.IsNullOrEmpty(correctSizeLink)) - { - throw new KavitaException($"Could not grab favicon from {baseUrl}"); - } - - var finalUrl = correctSizeLink; - - // If starts with //, it's coming usually from an offsite cdn - if (correctSizeLink.StartsWith("//")) - { - finalUrl = "https:" + correctSizeLink; - } - else if (!correctSizeLink.StartsWith(uri.Scheme)) - { - finalUrl = Url.Combine(baseUrl, correctSizeLink); - } - - _logger.LogTrace("Fetching favicon from {Url}", finalUrl); - // Download the favicon.ico file using Flurl - var faviconStream = await finalUrl - .AllowHttpStatus("2xx,304") - .GetStreamAsync(); - - // Create the destination file path - using var image = Image.PngloadStream(faviconStream); - var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat); - switch (encodeFormat) - { - case EncodeFormat.PNG: - image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - case EncodeFormat.WEBP: - image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - case EncodeFormat.AVIF: - image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - default: - throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); - } - - - _logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain); - return filename; - } catch (Exception ex) - { - _logger.LogError(ex, "Error downloading favicon for {Domain}", domain); - throw; - } - } - - public async Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat) - { - try - { - var publisherLink = await FallbackToKavitaReaderPublisher(publisherName); - if (string.IsNullOrEmpty(publisherLink)) - { - throw new KavitaException($"Could not grab publisher image for {publisherName}"); - } - - var finalUrl = publisherLink; - - _logger.LogTrace("Fetching publisher image from {Url}", finalUrl); - // Download the favicon.ico file using Flurl - var publisherStream = await finalUrl - .AllowHttpStatus("2xx,304") - .GetStreamAsync(); - - // Create the destination file path - using var image = Image.PngloadStream(publisherStream); - var filename = GetPublisherFormat(publisherName, encodeFormat); - switch (encodeFormat) - { - case EncodeFormat.PNG: - image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename)); - break; - case EncodeFormat.WEBP: - image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename)); - break; - case EncodeFormat.AVIF: - image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename)); - break; - default: - throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); - } - - - _logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName); - return filename; - } catch (Exception ex) - { - _logger.LogError(ex, "Error downloading image for {PublisherName}", publisherName); - throw; - } - } - private static (Vector3?, Vector3?) GetPrimarySecondaryColors(string imagePath) { using var image = Image.NewFromFile(imagePath); @@ -740,67 +573,12 @@ public class ImageService : IImageService }; } - private static async Task FallbackToKavitaReaderFavicon(string baseUrl) - { - var correctSizeLink = string.Empty; - var allOverrides = await "https://www.kavitareader.com/assets/favicons/urls.txt".GetStringAsync(); - if (!string.IsNullOrEmpty(allOverrides)) - { - var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty); - var externalFile = allOverrides - .Split("\n") - .FirstOrDefault(url => - cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) || - cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty) - )); - if (string.IsNullOrEmpty(externalFile)) - { - throw new KavitaException($"Could not grab favicon from {baseUrl}"); - } - - correctSizeLink = "https://www.kavitareader.com/assets/favicons/" + externalFile; - } - - return correctSizeLink; - } - - private static async Task FallbackToKavitaReaderPublisher(string publisherName) - { - var externalLink = string.Empty; - var allOverrides = await "https://www.kavitareader.com/assets/publishers/publishers.txt".GetStringAsync(); - if (!string.IsNullOrEmpty(allOverrides)) - { - var externalFile = allOverrides - .Split("\n") - .Select(publisherLine => - { - var tokens = publisherLine.Split("|"); - if (tokens.Length != 2) return null; - var aliases = tokens[0]; - // Multiple publisher aliases are separated by # - if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim()))) - { - return tokens[1]; - } - return null; - }) - .FirstOrDefault(url => !string.IsNullOrEmpty(url)); - - if (string.IsNullOrEmpty(externalFile)) - { - throw new KavitaException($"Could not grab publisher image for {publisherName}"); - } - - externalLink = "https://www.kavitareader.com/assets/publishers/" + externalFile; - } - - return externalLink; - } /// public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth) { + // TODO: This code has no concept of cropping nor Thumbnail Size try { using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth); @@ -816,6 +594,7 @@ public class ImageService : IImageService return string.Empty; } + /// /// Returns the name format for a chapter cover image /// @@ -968,7 +747,8 @@ public class ImageService : IImageService entity.SecondaryColor = colors.Secondary; } - public static Color HexToRgb(string? hex) + + public static (int R, int G, int B) HexToRgb(string? hex) { if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null"); @@ -992,7 +772,7 @@ public class ImageService : IImageService var g = Convert.ToInt32(hex.Substring(2, 2), 16); var b = Convert.ToInt32(hex.Substring(4, 2), 16); - return Color.FromArgb(r, g, b); + return (r, g, b); } diff --git a/API/Services/KoreaderService.cs b/API/Services/KoreaderService.cs new file mode 100644 index 000000000..a38e8c468 --- /dev/null +++ b/API/Services/KoreaderService.cs @@ -0,0 +1,91 @@ +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Koreader; +using API.DTOs.Progress; +using API.Extensions; +using API.Helpers; +using API.Helpers.Builders; +using Kavita.Common; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +#nullable enable + +public interface IKoreaderService +{ + Task SaveProgress(KoreaderBookDto koreaderBookDto, int userId); + Task GetProgress(string bookHash, int userId); +} + +public class KoreaderService : IKoreaderService +{ + private readonly IReaderService _readerService; + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly ILogger _logger; + + public KoreaderService(IReaderService readerService, IUnitOfWork unitOfWork, ILocalizationService localizationService, ILogger logger) + { + _readerService = readerService; + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _logger = logger; + } + + /// + /// Given a Koreader hash, locate the underlying file and generate/update a progress event. + /// + /// + /// + public async Task SaveProgress(KoreaderBookDto koreaderBookDto, int userId) + { + _logger.LogDebug("Saving Koreader progress for User ({UserId}): {KoreaderProgress}", userId, koreaderBookDto.Progress.Sanitize()); + var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(koreaderBookDto.Document); + if (file == null) throw new KavitaException(await _localizationService.Translate(userId, "file-missing")); + + var userProgressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId); + if (userProgressDto == null) + { + var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(file.ChapterId); + if (chapterDto == null) throw new KavitaException(await _localizationService.Translate(userId, "chapter-doesnt-exist")); + + var volumeDto = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapterDto.VolumeId); + if (volumeDto == null) throw new KavitaException(await _localizationService.Translate(userId, "volume-doesnt-exist")); + + userProgressDto = new ProgressDto() + { + ChapterId = file.ChapterId, + VolumeId = chapterDto.VolumeId, + SeriesId = volumeDto.SeriesId, + }; + } + // Update the bookScrollId if possible + KoreaderHelper.UpdateProgressDto(userProgressDto, koreaderBookDto.Progress); + + await _readerService.SaveReadingProgress(userProgressDto, userId); + } + + /// + /// Returns a Koreader Dto representing current book and the progress within + /// + /// + /// + /// + public async Task GetProgress(string bookHash, int userId) + { + var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + + var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(bookHash); + + if (file == null) throw new KavitaException(await _localizationService.Translate(userId, "file-missing")); + + var progressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId); + var koreaderProgress = KoreaderHelper.GetKoreaderPosition(progressDto); + + return new KoreaderBookDtoBuilder(bookHash).WithProgress(koreaderProgress) + .WithPercentage(progressDto?.PageNum, file.Pages) + .WithDeviceId(settingsDto.InstallId, userId) + .Build(); + } +} diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index ab3ad3d89..7db35bb8e 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using API.Data; +using API.DTOs; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Hosting; @@ -11,11 +12,13 @@ namespace API.Services; #nullable enable + + public interface ILocalizationService { Task Get(string locale, string key, params object[] args); Task Translate(int userId, string key, params object[] args); - IEnumerable GetLocales(); + IEnumerable GetLocales(); } public class LocalizationService : ILocalizationService @@ -134,14 +137,260 @@ public class LocalizationService : ILocalizationService /// Returns all available locales that exist on both the Frontend and the Backend ///
/// - public IEnumerable GetLocales() + public IEnumerable GetLocales() { var uiLanguages = _directoryService - .GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json") - .Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty)); + .GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json"); var backendLanguages = _directoryService - .GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json") - .Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty)); - return uiLanguages.Intersect(backendLanguages).Distinct(); + .GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json"); + + var locales = new Dictionary(); + var localeCounts = new Dictionary>(); // fileName -> (nonEmptyValues, totalKeys) + + // First pass: collect all files and count non-empty strings + + // Process UI language files + foreach (var file in uiLanguages) + { + var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file); + var fileContent = _directoryService.FileSystem.File.ReadAllText(file); + var hash = ComputeHash(fileContent); + + var counts = CalculateNonEmptyStrings(fileContent); + + if (localeCounts.TryGetValue(fileName, out var existingCount)) + { + // Update existing counts + localeCounts[fileName] = Tuple.Create( + existingCount.Item1 + counts.Item1, + existingCount.Item2 + counts.Item2 + ); + } + else + { + // Add new counts + localeCounts[fileName] = counts; + } + + if (!locales.TryGetValue(fileName, out var locale)) + { + locales[fileName] = new KavitaLocale + { + FileName = fileName, + RenderName = GetDisplayName(fileName), + TranslationCompletion = 0, // Will be calculated later + IsRtL = IsRightToLeft(fileName), + Hash = hash + }; + } + else + { + // Update existing locale hash + locale.Hash = CombineHashes(locale.Hash, hash); + } + } + + // Process backend language files + foreach (var file in backendLanguages) + { + var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file); + var fileContent = _directoryService.FileSystem.File.ReadAllText(file); + var hash = ComputeHash(fileContent); + + var counts = CalculateNonEmptyStrings(fileContent); + + if (localeCounts.TryGetValue(fileName, out var existingCount)) + { + // Update existing counts + localeCounts[fileName] = Tuple.Create( + existingCount.Item1 + counts.Item1, + existingCount.Item2 + counts.Item2 + ); + } + else + { + // Add new counts + localeCounts[fileName] = counts; + } + + if (!locales.TryGetValue(fileName, out var locale)) + { + locales[fileName] = new KavitaLocale + { + FileName = fileName, + RenderName = GetDisplayName(fileName), + TranslationCompletion = 0, // Will be calculated later + IsRtL = IsRightToLeft(fileName), + Hash = hash + }; + } + else + { + // Update existing locale hash + locale.Hash = CombineHashes(locale.Hash, hash); + } + } + + // Second pass: calculate completion percentages based on English total + if (localeCounts.TryGetValue("en", out var englishCounts) && englishCounts.Item2 > 0) + { + var englishTotalKeys = englishCounts.Item2; + + foreach (var locale in locales.Values) + { + if (localeCounts.TryGetValue(locale.FileName, out var counts)) + { + // Calculate percentage based on English total keys + locale.TranslationCompletion = (float)counts.Item1 / englishTotalKeys * 100; + } + } + } + + return locales.Values; + } + + // Helper methods that would need to be implemented + private static string ComputeHash(string content) + { + // Implement a hashing algorithm (e.g., SHA256, MD5) to generate a hash for the content + using var md5 = System.Security.Cryptography.MD5.Create(); + var inputBytes = System.Text.Encoding.UTF8.GetBytes(content); + var hashBytes = md5.ComputeHash(inputBytes); + return Convert.ToBase64String(hashBytes); + } + + private static string CombineHashes(string hash1, string hash2) + { + // Combine two hashes, possibly by concatenating and rehashing + return ComputeHash(hash1 + hash2); + } + + private static string GetDisplayName(string fileName) + { + // Map the filename to a human-readable display name + // This could use a lookup table or follow a naming convention + try + { + var cultureInfo = new System.Globalization.CultureInfo(fileName.Replace('_', '-')); + return cultureInfo.NativeName; + } + catch + { + // Fall back to the file name if the culture isn't recognized + return fileName; + } + } + + private static bool IsRightToLeft(string fileName) + { + // Determine if the language is right-to-left + try + { + var cultureInfo = new System.Globalization.CultureInfo(fileName); + return cultureInfo.TextInfo.IsRightToLeft; + } + catch + { + return false; // Default to left-to-right + } + } + + private static float CalculateTranslationCompletion(string fileContent) + { + try + { + var jsonObject = System.Text.Json.JsonDocument.Parse(fileContent); + + int totalKeys = 0; + int nonEmptyValues = 0; + + // Count all keys and non-empty values + CountNonEmptyValues(jsonObject.RootElement, ref totalKeys, ref nonEmptyValues); + + return totalKeys > 0 ? (nonEmptyValues * 1f) / totalKeys * 100 : 0; + } + catch (Exception ex) + { + // Consider logging the exception + return 0; // Return 0% completion if there's an error parsing + } + } + private static Tuple CalculateNonEmptyStrings(string fileContent) + { + try + { + var jsonObject = JsonDocument.Parse(fileContent); + + var totalKeys = 0; + var nonEmptyValues = 0; + + // Count all keys and non-empty values + CountNonEmptyValues(jsonObject.RootElement, ref totalKeys, ref nonEmptyValues); + + return Tuple.Create(nonEmptyValues, totalKeys); + } + catch (Exception) + { + // Consider logging the exception + return Tuple.Create(0, 0); // Return 0% completion if there's an error parsing + } + } + + private static void CountNonEmptyValues(JsonElement element, ref int totalKeys, ref int nonEmptyValues) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (property.Value.ValueKind == System.Text.Json.JsonValueKind.String) + { + totalKeys++; + var value = property.Value.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + nonEmptyValues++; + } + } + else + { + // Recursively process nested objects + CountNonEmptyValues(property.Value, ref totalKeys, ref nonEmptyValues); + } + } + } + else if (element.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + CountNonEmptyValues(item, ref totalKeys, ref nonEmptyValues); + } + } + } + + private void CountEntries(System.Text.Json.JsonElement element, ref int total, ref int translated) + { + if (element.ValueKind == System.Text.Json.JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + CountEntries(property.Value, ref total, ref translated); + } + } + else if (element.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + CountEntries(item, ref total, ref translated); + } + } + else if (element.ValueKind == System.Text.Json.JsonValueKind.String) + { + total++; + string value = element.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + translated++; + } + } } } diff --git a/API/Services/MediaConversionService.cs b/API/Services/MediaConversionService.cs index 9f6b18374..fc3e5f318 100644 --- a/API/Services/MediaConversionService.cs +++ b/API/Services/MediaConversionService.cs @@ -222,6 +222,10 @@ public class MediaConversionService : IMediaConversionService { if (string.IsNullOrEmpty(series.CoverImage)) continue; series.CoverImage = series.GetCoverImage(); + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); + } _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index b444ecde2..e0e86f4dc 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -28,7 +28,7 @@ public interface IMetadataService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false, bool forceColorScape = false); /// - /// Performs a forced refresh of cover images just for a series and it's nested entities + /// Performs a forced refresh of cover images just for a series, and it's nested entities /// /// /// @@ -75,12 +75,12 @@ public class MetadataService : IMetadataService /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image /// Convert image to Encoding Format when extracting the cover /// Force colorscape gen - private Task UpdateChapterCoverImage(Chapter? chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false) + private bool UpdateChapterCoverImage(Chapter? chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false) { - if (chapter == null) return Task.FromResult(false); + if (chapter == null) return false; var firstFile = chapter.Files.MinBy(x => x.Chapter); - if (firstFile == null) return Task.FromResult(false); + if (firstFile == null) return false; if (!_cacheHelper.ShouldUpdateCoverImage( _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), @@ -93,7 +93,7 @@ public class MetadataService : IMetadataService _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); } - return Task.FromResult(false); + return false; } @@ -107,7 +107,7 @@ public class MetadataService : IMetadataService _unitOfWork.ChapterRepository.Update(chapter); _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); - return Task.FromResult(true); + return true; } private void UpdateChapterLastModified(Chapter chapter, bool forceUpdate) @@ -135,10 +135,10 @@ public class MetadataService : IMetadataService /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image /// Force updating colorscape - private Task UpdateVolumeCoverImage(Volume? volume, bool forceUpdate, bool forceColorScape = false) + private bool UpdateVolumeCoverImage(Volume? volume, bool forceUpdate, bool forceColorScape = false) { // We need to check if Volume coverImage matches first chapters if forceUpdate is false - if (volume == null) return Task.FromResult(false); + if (volume == null) return false; if (!_cacheHelper.ShouldUpdateCoverImage( _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage), @@ -150,7 +150,7 @@ public class MetadataService : IMetadataService _unitOfWork.VolumeRepository.Update(volume); _updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume)); } - return Task.FromResult(false); + return false; } if (!volume.CoverImageLocked) @@ -162,7 +162,7 @@ public class MetadataService : IMetadataService if (firstChapter == null) { firstChapter = volume.Chapters.MinBy(x => x.SortOrder, ChapterSortComparerDefaultFirst.Default); - if (firstChapter == null) return Task.FromResult(false); + if (firstChapter == null) return false; } volume.CoverImage = firstChapter.CoverImage; @@ -171,7 +171,7 @@ public class MetadataService : IMetadataService _updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume)); - return Task.FromResult(true); + return true; } /// @@ -179,9 +179,9 @@ public class MetadataService : IMetadataService /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - private Task UpdateSeriesCoverImage(Series? series, bool forceUpdate, bool forceColorScape = false) + private void UpdateSeriesCoverImage(Series? series, bool forceUpdate, bool forceColorScape = false) { - if (series == null) return Task.CompletedTask; + if (series == null) return; if (!_cacheHelper.ShouldUpdateCoverImage( _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), @@ -194,16 +194,19 @@ public class MetadataService : IMetadataService _updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series)); } - return Task.CompletedTask; + return; } series.Volumes ??= []; series.CoverImage = series.GetCoverImage(); + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); + } _imageService.UpdateColorScape(series); _updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series)); - return Task.CompletedTask; } @@ -213,7 +216,7 @@ public class MetadataService : IMetadataService /// /// /// - private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false) + private void ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false) { _logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName); try @@ -226,8 +229,8 @@ public class MetadataService : IMetadataService var index = 0; foreach (var chapter in volume.Chapters) { - var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat, coverImageSize, forceColorScape); - // If cover was update, either the file has changed or first scan and we should force a metadata update + var chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat, coverImageSize, forceColorScape); + // If cover was update, either the file has changed or first scan, and we should force a metadata update UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated); if (index == 0 && chapterUpdated) { @@ -237,7 +240,7 @@ public class MetadataService : IMetadataService index++; } - var volumeUpdated = await UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate, forceColorScape); + var volumeUpdated = UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate, forceColorScape); if (volumeIndex == 0 && volumeUpdated) { firstVolumeUpdated = true; @@ -245,7 +248,7 @@ public class MetadataService : IMetadataService volumeIndex++; } - await UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate, forceColorScape); + UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate, forceColorScape); } catch (Exception ex) { @@ -311,7 +314,7 @@ public class MetadataService : IMetadataService try { - await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape); + ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape); } catch (Exception ex) { @@ -362,6 +365,7 @@ public class MetadataService : IMetadataService return; } + // TODO: Cache this because it's called a lot during scans var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var encodeFormat = settings.EncodeMediaAs; var coverImageSize = settings.CoverImageSize; @@ -382,7 +386,7 @@ public class MetadataService : IMetadataService await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name)); - await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape); + ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape); if (_unitOfWork.HasChanges()) diff --git a/API/Services/PersonService.cs b/API/Services/PersonService.cs new file mode 100644 index 000000000..ff0049cbe --- /dev/null +++ b/API/Services/PersonService.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities.Person; +using API.Extensions; +using API.Helpers.Builders; + +namespace API.Services; + +public interface IPersonService +{ + /// + /// Adds src as an alias to dst, this is a destructive operation + /// + /// Merged person + /// Remaining person + /// The entities passed as arguments **must** include all relations + /// + Task MergePeopleAsync(Person src, Person dst); + + /// + /// Adds the alias to the person, requires that the aliases are not shared with anyone else + /// + /// This method does NOT commit changes + /// + /// + /// + Task UpdatePersonAliasesAsync(Person person, IList aliases); +} + +public class PersonService(IUnitOfWork unitOfWork): IPersonService +{ + + public async Task MergePeopleAsync(Person src, Person dst) + { + if (dst.Id == src.Id) return; + + if (string.IsNullOrWhiteSpace(dst.Description) && !string.IsNullOrWhiteSpace(src.Description)) + { + dst.Description = src.Description; + } + + if (dst.MalId == 0 && src.MalId != 0) + { + dst.MalId = src.MalId; + } + + if (dst.AniListId == 0 && src.AniListId != 0) + { + dst.AniListId = src.AniListId; + } + + if (dst.HardcoverId == null && src.HardcoverId != null) + { + dst.HardcoverId = src.HardcoverId; + } + + if (dst.Asin == null && src.Asin != null) + { + dst.Asin = src.Asin; + } + + if (dst.CoverImage == null && src.CoverImage != null) + { + dst.CoverImage = src.CoverImage; + } + + MergeChapterPeople(dst, src); + MergeSeriesMetadataPeople(dst, src); + + dst.Aliases.Add(new PersonAliasBuilder(src.Name).Build()); + + foreach (var alias in src.Aliases) + { + dst.Aliases.Add(alias); + } + + unitOfWork.PersonRepository.Remove(src); + unitOfWork.PersonRepository.Update(dst); + await unitOfWork.CommitAsync(); + } + + private static void MergeChapterPeople(Person dst, Person src) + { + + foreach (var chapter in src.ChapterPeople) + { + var alreadyPresent = dst.ChapterPeople + .Any(x => x.ChapterId == chapter.ChapterId && x.Role == chapter.Role); + + if (alreadyPresent) continue; + + dst.ChapterPeople.Add(new ChapterPeople + { + Role = chapter.Role, + ChapterId = chapter.ChapterId, + Person = dst, + KavitaPlusConnection = chapter.KavitaPlusConnection, + OrderWeight = chapter.OrderWeight, + }); + } + } + + private static void MergeSeriesMetadataPeople(Person dst, Person src) + { + foreach (var series in src.SeriesMetadataPeople) + { + var alreadyPresent = dst.SeriesMetadataPeople + .Any(x => x.SeriesMetadataId == series.SeriesMetadataId && x.Role == series.Role); + + if (alreadyPresent) continue; + + dst.SeriesMetadataPeople.Add(new SeriesMetadataPeople + { + SeriesMetadataId = series.SeriesMetadataId, + Role = series.Role, + Person = dst, + KavitaPlusConnection = series.KavitaPlusConnection, + OrderWeight = series.OrderWeight, + }); + } + } + + public async Task UpdatePersonAliasesAsync(Person person, IList aliases) + { + var normalizedAliases = aliases + .Select(a => a.ToNormalized()) + .Where(a => !string.IsNullOrEmpty(a) && a != person.NormalizedName) + .ToList(); + + if (normalizedAliases.Count == 0) + { + person.Aliases = []; + return true; + } + + var others = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedAliases); + others = others.Where(p => p.Id != person.Id).ToList(); + + if (others.Count != 0) return false; + + person.Aliases = aliases.Select(a => new PersonAliasBuilder(a).Build()).ToList(); + + return true; + } +} diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 4bc55eb88..0777e1baa 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -1,58 +1,47 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Collection; +using API.DTOs.KavitaPlus.ExternalMetadata; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Metadata.Matching; +using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Extensions; using API.Helpers; +using API.Helpers.Builders; +using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner.Parser; +using API.SignalR; using AutoMapper; using Flurl.Http; using Hangfire; using Kavita.Common; -using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; namespace API.Services.Plus; #nullable enable -/// -/// Used for matching and fetching metadata on a series -/// -internal class ExternalMetadataIdsDto -{ - public long? MalId { get; set; } - public int? AniListId { get; set; } - public string? SeriesName { get; set; } - public string? LocalizedSeriesName { get; set; } - public MediaFormat? PlusMediaFormat { get; set; } = MediaFormat.Unknown; -} - -internal class SeriesDetailPlusApiDto -{ - public IEnumerable Recommendations { get; set; } - public IEnumerable Reviews { get; set; } - public IEnumerable Ratings { get; set; } - public int? AniListId { get; set; } - public long? MalId { get; set; } -} public interface IExternalMetadataService { Task GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId); - Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType); - Task ForceKavitaPlusRefresh(int seriesId); + Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType); Task FetchExternalDataTask(); /// /// This is an entry point and provides a level of protection against calling upstream API. Will only allow 100 new @@ -60,10 +49,14 @@ public interface IExternalMetadataService /// /// /// - /// - Task GetNewSeriesData(int seriesId, LibraryType libraryType); + /// If the fetch was made + Task FetchSeriesMetadata(int seriesId, LibraryType libraryType); Task> GetStacksForUser(int userId); + Task> MatchSeries(MatchSeriesDto dto); + Task FixSeriesMatch(int seriesId, int? aniListId, long? malId, int? cbrId); + Task UpdateSeriesDontMatch(int seriesId, bool dontMatch); + Task WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId); } public class ExternalMetadataService : IExternalMetadataService @@ -72,28 +65,38 @@ public class ExternalMetadataService : IExternalMetadataService private readonly ILogger _logger; private readonly IMapper _mapper; private readonly ILicenseService _licenseService; + private readonly IScrobblingService _scrobblingService; + private readonly IEventHub _eventHub; + private readonly ICoverDbService _coverDbService; + private readonly IKavitaPlusApiService _kavitaPlusApiService; private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30); - public static readonly ImmutableArray NonEligibleLibraryTypes = ImmutableArray.Create - (LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine); + public static readonly HashSet NonEligibleLibraryTypes = + [LibraryType.Comic, LibraryType.Book, LibraryType.Image]; private readonly SeriesDetailPlusDto _defaultReturn = new() { + Series = null, Recommendations = null, - Ratings = ArraySegment.Empty, - Reviews = ArraySegment.Empty + Ratings = [], + Reviews = [] }; // Allow 50 requests per 24 hours private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); + private static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$"); - public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, ILicenseService licenseService) + public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, + ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService, + IKavitaPlusApiService kavitaPlusApiService) { _unitOfWork = unitOfWork; _logger = logger; _mapper = mapper; _licenseService = licenseService; + _scrobblingService = scrobblingService; + _eventHub = eventHub; + _coverDbService = coverDbService; + _kavitaPlusApiService = kavitaPlusApiService; - - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } /// @@ -110,75 +113,58 @@ public class ExternalMetadataService : IExternalMetadataService /// This is a task that runs on a schedule and slowly fetches data from Kavita+ to keep /// data in the DB non-stale and fetched. /// - /// To avoid blasting Kavita+ API, this only processes a few records. The goal is to slowly build + /// To avoid blasting Kavita+ API, this only processes 25 records. The goal is to slowly build out/refresh the data /// [DisableConcurrentExecution(60 * 60 * 60)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task FetchExternalDataTask() { // Find all Series that are eligible and limit - var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeriesIdsWithoutMetadata(25); + var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25); if (ids.Count == 0) return; + ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, true); - _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+", ids.Count); + _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+: {Ids}", ids.Count, string.Join(',', ids)); var count = 0; + var successfulMatches = new List(); var libTypes = await _unitOfWork.LibraryRepository.GetLibraryTypesBySeriesIdsAsync(ids); foreach (var seriesId in ids) { var libraryType = libTypes[seriesId]; - await GetNewSeriesData(seriesId, libraryType); - await Task.Delay(1500); - count++; + var success = await FetchSeriesMetadata(seriesId, libraryType); + if (success) + { + count++; + successfulMatches.Add(seriesId); + } + await Task.Delay(6000); // Currently AL is degraded and has 30 requests/min, give a little padding since this is a background request } - _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count); + _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} / {Total} series data from Kavita+: {Ids}", count, ids.Count, string.Join(',', successfulMatches)); } - /// - /// Removes from Blacklist and Invalidates the cache - /// - /// - /// - public async Task ForceKavitaPlusRefresh(int seriesId) - { - if (!await _licenseService.HasActiveLicense()) return; - var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId); - if (!IsPlusEligible(libraryType)) return; - - // Remove from Blacklist if applicable - await _unitOfWork.ExternalSeriesMetadataRepository.RemoveFromBlacklist(seriesId); - - var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId); - if (metadata == null) return; - - metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache); - await _unitOfWork.CommitAsync(); - } /// /// Fetches data from Kavita+ /// /// /// - public async Task GetNewSeriesData(int seriesId, LibraryType libraryType) + /// If a successful match was made + public async Task FetchSeriesMetadata(int seriesId, LibraryType libraryType) { - if (!IsPlusEligible(libraryType)) return; - if (!await _licenseService.HasActiveLicense()) return; + if (!IsPlusEligible(libraryType)) return false; + if (!await _licenseService.HasActiveLicense()) return false; // Generate key based on seriesId and libraryType or any unique identifier for the request // Check if the request is allowed based on the rate limit if (!RateLimiter.TryAcquire(string.Empty)) { // Request not allowed due to rate limit - _logger.LogDebug("Rate Limit hit for Kavita+ prefetch"); - return; + _logger.LogInformation("Rate Limit hit for Kavita+ prefetch"); + return false; } - _logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId); // Prefetch SeriesDetail data - await GetSeriesDetailPlus(seriesId, libraryType); - - // TODO: Fetch Series Metadata (Summary, etc) - + return await GetSeriesDetailPlus(seriesId, libraryType) != null; } public async Task> GetStacksForUser(int userId) @@ -197,15 +183,7 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogDebug("Fetching Kavita+ for MAL Stacks for user {UserName}", user.MalUserName); var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var result = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={user.MalUserName}") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .GetJsonAsync>(); + var result = await _kavitaPlusApiService.GetMalStacks(user.MalUserName, license); if (result == null) { @@ -221,6 +199,75 @@ public class ExternalMetadataService : IExternalMetadataService } } + /// + /// Returns the match results for a Series from UI Flow + /// + /// + /// Will extract alternative names like Localized name, year will send as ReleaseYear but fallback to Comic Vine syntax if applicable + /// + /// + /// + public async Task> MatchSeries(MatchSeriesDto dto) + { + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, + SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata | SeriesIncludes.Library); + if (series == null) return []; + + var potentialAnilistId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.AniListWeblinkWebsite); + var potentialMalId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.MalWeblinkWebsite); + + var format = series.Library.Type.ConvertToPlusMediaFormat(series.Format); + var otherNames = ExtractAlternativeNames(series); + + var year = series.Metadata.ReleaseYear; + if (year == 0 && format == PlusMediaFormat.Comic && !string.IsNullOrWhiteSpace(series.Name)) + { + var potentialYear = Parser.ParseYear(series.Name); + if (!string.IsNullOrEmpty(potentialYear)) + { + year = int.Parse(potentialYear); + } + } + + var matchRequest = new MatchSeriesRequestDto() + { + Format = format, + Query = dto.Query, + SeriesName = series.Name, + AlternativeNames = otherNames, + Year = year, + AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), + MalId = potentialMalId ?? ScrobblingService.GetMalId(series) + }; + + try + { + var results = await _kavitaPlusApiService.MatchSeries(matchRequest); + + // Some summaries can contain multiple
s, we need to ensure it's only 1 + foreach (var result in results) + { + result.Series.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(result.Series.Summary)); + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error happened during the request to Kavita+ API"); + } + + return ArraySegment.Empty; + } + + private static List ExtractAlternativeNames(Series series) + { + List altNames = [series.LocalizedName, series.OriginalName]; + return altNames.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); + } + + /// /// Retrieves Metadata about a Recommended External Series /// @@ -237,9 +284,7 @@ public class ExternalMetadataService : IExternalMetadataService } // This is for the Series drawer. We can get this extra information during the initial SeriesDetail call so it's all coming from the DB - - var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var details = await GetSeriesDetail(license, aniListId, malId, seriesId); + var details = await GetSeriesDetail(aniListId, malId, seriesId); return details; @@ -249,16 +294,18 @@ public class ExternalMetadataService : IExternalMetadataService /// Returns Series Detail data from Kavita+ - Review, Recs, Ratings ///
/// + /// /// - public async Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType) + public async Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType) { if (!IsPlusEligible(libraryType) || !await _licenseService.HasActiveLicense()) return _defaultReturn; - // Check blacklist (bad matches) - if (await _unitOfWork.ExternalSeriesMetadataRepository.IsBlacklistedSeries(seriesId)) return _defaultReturn; + // Check blacklist (bad matches) or if there is a don't match + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null || !series.WillScrobble()) return _defaultReturn; var needsRefresh = - await _unitOfWork.ExternalSeriesMetadataRepository.ExternalSeriesMetadataNeedsRefresh(seriesId); + await _unitOfWork.ExternalSeriesMetadataRepository.NeedsDataRefresh(seriesId); if (!needsRefresh) { @@ -266,28 +313,172 @@ public class ExternalMetadataService : IExternalMetadataService return await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesDetailPlusDto(seriesId); } + var data = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(seriesId); + if (data == null) return _defaultReturn; + + // Get from Kavita+ API the Full Series metadata with rec/rev and cache to ExternalMetadata tables try { - var data = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(seriesId); - if (data == null) return _defaultReturn; - _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", data.SeriesName); + return await FetchExternalMetadataForSeries(seriesId, libraryType, data); + } + catch (KavitaException ex) + { + _logger.LogError(ex, "Rate limit hit fetching metadata"); + // This can happen when we hit rate limit + return _defaultReturn; + } + } - var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(data) - .ReceiveJson(); + /// + /// This will override any sort of matching that was done prior and force it to be what the user Selected + /// + /// + /// + /// + /// + public async Task FixSeriesMatch(int seriesId, int? aniListId, long? malId, int? cbrId) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null) return; + + // Remove from Blacklist + series.IsBlacklisted = false; + series.DontMatch = false; + _unitOfWork.SeriesRepository.Update(series); + + // Refetch metadata with a Direct lookup + try + { + var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type, + new PlusSeriesRequestDto() + { + AniListId = aniListId, + MalId = malId, + CbrId = cbrId, + MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), + SeriesName = series.Name // Required field, not used since AniList/Mal Id are passed + }); + + if (metadata.Series == null) + { + _logger.LogError("Unable to Match {SeriesName} with Kavita+ Series with Id: {AniListId}/{MalId}/{CbrId}", + series.Name, aniListId, malId, cbrId); + return; + } + + // Find all scrobble events and rewrite them to be the correct + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + _unitOfWork.ScrobbleRepository.Remove(events); + + // Find all scrobble errors and remove them + var errors = await _unitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(seriesId); + _unitOfWork.ScrobbleRepository.Remove(errors); + + await _unitOfWork.CommitAsync(); + + // Regenerate all events for the series for all users + BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistoryForSeries(seriesId)); + + // Name can be null on Series even with a direct match + _logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, + metadata.Series.Name); + } + catch (KavitaException ex) + { + // We can't rethrow because Fix match is done in a background thread and Hangfire will requeue multiple times + _logger.LogInformation(ex, "Rate limit hit for matching {SeriesName} with Kavita+", series.Name); + // Fire SignalR event about this + await _eventHub.SendMessageAsync(MessageFactory.ExternalMatchRateLimitError, + MessageFactory.ExternalMatchRateLimitErrorEvent(series.Id, series.Name)); + } + } + + /// + /// Sets a series to Don't Match and removes all previously cached + /// + /// + public async Task UpdateSeriesDontMatch(int seriesId, bool dontMatch) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.ExternalMetadata); + if (series == null) return; + + _logger.LogInformation("User has asked Kavita to stop matching/scrobbling on {SeriesName}", series.Name); + + series.DontMatch = dontMatch; + + if (dontMatch) + { + // When we set as DontMatch, we will clear existing External Metadata + var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(series.ExternalSeriesMetadata); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations); + } + + _unitOfWork.SeriesRepository.Update(series); + + await _unitOfWork.CommitAsync(); + } + + /// + /// Requests the full SeriesDetail (rec, review, metadata) data for a Series. Will save to ExternalMetadata tables. + /// + /// + /// + /// + /// + private async Task FetchExternalMetadataForSeries(int seriesId, LibraryType libraryType, PlusSeriesRequestDto data) + { + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null) + { + return _defaultReturn; + } + + try + { + _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", string.IsNullOrEmpty(data.SeriesName) ? data.AniListId : data.SeriesName); + SeriesDetailPlusApiDto? result = null; + + try + { + // This returns an AniListSeries and Match returns ExternalSeriesDto + result = await _kavitaPlusApiService.GetSeriesDetail(data); + } + catch (FlurlHttpException ex) + { + var errorMessage = await ex.GetResponseStringAsync(); + // Trim quotes if the response is a JSON string + errorMessage = errorMessage.Trim('"'); + + if (ex.StatusCode == 400) + { + if (errorMessage.Contains("Too many Requests")) + { + _logger.LogDebug("Hit rate limit, will retry in 3 seconds"); + await Task.Delay(3000); + + result = await _kavitaPlusApiService.GetSeriesDetail(data); + } + else if (errorMessage.Contains("Unknown Series")) + { + series.IsBlacklisted = true; + await _unitOfWork.CommitAsync(); + } + } + } + + if (result == null) + { + _logger.LogInformation("Hit rate limit twice, try again later"); + return _defaultReturn; + } // Clear out existing results - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); - var externalSeriesMetadata = await GetExternalSeriesMetadataForSeries(seriesId, series!); + var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations); @@ -303,12 +494,13 @@ public class ExternalMetadataService : IExternalMetadataService { var rating = _mapper.Map(r); rating.SeriesId = externalSeriesMetadata.SeriesId; + rating.ProviderUrl = r.ProviderUrl; return rating; }).ToList(); // Recommendations - externalSeriesMetadata.ExternalRecommendations ??= new List(); + externalSeriesMetadata.ExternalRecommendations ??= []; var recs = await ProcessRecommendations(libraryType, result.Recommendations, externalSeriesMetadata); var extRatings = externalSeriesMetadata.ExternalRatings @@ -321,35 +513,1268 @@ public class ExternalMetadataService : IExternalMetadataService if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value; if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value; - await _unitOfWork.CommitAsync(); + if (result.CbrId.HasValue) externalSeriesMetadata.CbrId = result.CbrId.Value; + + // If there is metadata and the user has metadata download turned on + var madeMetadataModification = false; + if (result.Series != null && series.Library.AllowMetadataMatching) + { + externalSeriesMetadata.Series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + + try + { + madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId); + if (madeMetadataModification) + { + _unitOfWork.SeriesRepository.Update(series); + _unitOfWork.SeriesRepository.Update(series.Metadata); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when trying to write Series metadata from Kavita+"); + } + + } + + // WriteExternalMetadataToSeries will commit but not always + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + if (madeMetadataModification) + { + // Inform the UI of the update + await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, MessageFactory.ScanSeriesEvent(series.LibraryId, series.Id, series.Name), false); + } return new SeriesDetailPlusDto() { Recommendations = recs, Ratings = result.Ratings, - Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map(r)) + Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map(r)), + Series = result.Series }; } catch (FlurlHttpException ex) { + var errorMessage = await ex.GetResponseStringAsync(); + // Trim quotes if the response is a JSON string + errorMessage = errorMessage.Trim('"'); + if (ex.StatusCode == 500) { return _defaultReturn; } + + if (ex.StatusCode == 400 && errorMessage.Contains("Too many Requests")) + { + throw new KavitaException("Too many requests, slow down"); + } } catch (Exception ex) { - _logger.LogError(ex, "An error happened during the request to Kavita+ API"); + if (ex.Message.Contains("Too Many Requests")) + { + throw new KavitaException("Too many requests, slow down"); + } + + _logger.LogError(ex, "Unable to fetch external series metadata from Kavita+"); } // Blacklist the series as it wasn't found in Kavita+ - await _unitOfWork.ExternalSeriesMetadataRepository.CreateBlacklistedSeries(seriesId); + series.IsBlacklisted = true; + await _unitOfWork.CommitAsync(); return _defaultReturn; } + /// + /// Given external metadata from Kavita+, write as much as possible to the Kavita series as possible + /// + /// + /// + /// + public async Task WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId) + { + var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + if (!settings.Enabled) return false; - private async Task GetExternalSeriesMetadataForSeries(int seriesId, Series series) + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Related); + if (series == null) return false; + + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(); + + _logger.LogInformation("Writing External metadata to Series {SeriesName}", series.Name); + + var madeModification = false; + var processedGenres = new List(); + var processedTags = new List(); + + madeModification = UpdateSummary(series, settings, externalMetadata) || madeModification; + madeModification = UpdateReleaseYear(series, settings, externalMetadata) || madeModification; + madeModification = UpdateLocalizedName(series, settings, externalMetadata) || madeModification; + madeModification = await UpdatePublicationStatus(series, settings, externalMetadata) || madeModification; + + // Apply field mappings + GenerateGenreAndTagLists(externalMetadata, settings, ref processedTags, ref processedGenres); + + madeModification = await UpdateGenres(series, settings, externalMetadata, processedGenres) || madeModification; + madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification; + madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification; + + var staff = await SetNameAndAddAliases(settings, externalMetadata.Staff); + + madeModification = await UpdateWriters(series, settings, staff) || madeModification; + madeModification = await UpdateArtists(series, settings, staff) || madeModification; + madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification; + + madeModification = await UpdateRelationships(series, settings, externalMetadata.Relations, defaultAdmin) || madeModification; + madeModification = await UpdateCoverImage(series, settings, externalMetadata) || madeModification; + + madeModification = await UpdateChapters(series, settings, externalMetadata) || madeModification; + + return madeModification; + } + + private async Task> SetNameAndAddAliases(MetadataSettingsDto settings, IList? staff) + { + if (staff == null || staff.Count == 0) return []; + + var nameMappings = staff.Select(s => new + { + Staff = s, + PreferredName = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}", + AlternativeName = !settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}" + }).ToList(); + + var preferredNames = nameMappings.Select(n => n.PreferredName.ToNormalized()).Distinct().ToList(); + var alternativeNames = nameMappings.Select(n => n.AlternativeName.ToNormalized()).Distinct().ToList(); + + var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(preferredNames.Union(alternativeNames).ToList()); + var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople); + + var modified = false; + foreach (var mapping in nameMappings) + { + mapping.Staff.Name = mapping.PreferredName; + + if (existingPeopleDictionary.ContainsKey(mapping.PreferredName.ToNormalized())) + { + continue; + } + + + if (existingPeopleDictionary.TryGetValue(mapping.AlternativeName.ToNormalized(), out var person)) + { + modified = true; + person.Aliases.Add(new PersonAliasBuilder(mapping.PreferredName).Build()); + } + } + + if (modified) + { + await _unitOfWork.CommitAsync(); + } + + return [.. staff]; + } + + private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings, + ref List processedTags, ref List processedGenres) + { + externalMetadata.Tags ??= []; + externalMetadata.Genres ??= []; + + var mappings = ApplyFieldMappings(externalMetadata.Tags.Select(t => t.Name), MetadataFieldType.Tag, settings.FieldMappings); + if (mappings.TryGetValue(MetadataFieldType.Tag, out var tagsToTags)) + { + processedTags.AddRange(tagsToTags); + } + if (mappings.TryGetValue(MetadataFieldType.Genre, out var tagsToGenres)) + { + processedGenres.AddRange(tagsToGenres); + } + + mappings = ApplyFieldMappings(externalMetadata.Genres, MetadataFieldType.Genre, settings.FieldMappings); + if (mappings.TryGetValue(MetadataFieldType.Tag, out var genresToTags)) + { + processedTags.AddRange(genresToTags); + } + if (mappings.TryGetValue(MetadataFieldType.Genre, out var genresToGenres)) + { + processedGenres.AddRange(genresToGenres); + } + + processedTags = ApplyBlackWhiteList(settings, MetadataFieldType.Tag, processedTags); + processedGenres = ApplyBlackWhiteList(settings, MetadataFieldType.Genre, processedGenres); + } + + private async Task UpdateRelationships(Series series, MetadataSettingsDto settings, IList? externalMetadataRelations, AppUser defaultAdmin) + { + if (!settings.EnableRelationships) return false; + + if (externalMetadataRelations == null || externalMetadataRelations.Count == 0 || defaultAdmin == null) + { + return false; + } + + foreach (var relation in externalMetadataRelations.Where(r => r.Relation != RelationKind.Parent)) + { + List names = new [] {relation.SeriesName.PreferredTitle, relation.SeriesName.RomajiTitle, relation.SeriesName.EnglishTitle, relation.SeriesName.NativeTitle}.Where(s => !string.IsNullOrEmpty(s)).ToList()!; + var relatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName( + names, + relation.PlusMediaFormat.GetMangaFormats(), + defaultAdmin.Id, + relation.AniListId, + SeriesIncludes.Related); + + // Skip if no related series found or series is the parent + if (relatedSeries == null || relatedSeries.Id == series.Id || relation.Relation == RelationKind.Parent) continue; + + // Check if the relationship already exists + var relationshipExists = series.Relations.Any(r => + r.TargetSeriesId == relatedSeries.Id && r.RelationKind == relation.Relation); + + if (relationshipExists) continue; + + // Add new relationship + var newRelation = new SeriesRelation + { + RelationKind = relation.Relation, + TargetSeriesId = relatedSeries.Id, + SeriesId = series.Id, + }; + series.Relations.Add(newRelation); + + // Handle sequel/prequel: add reverse relationship + if (relation.Relation is RelationKind.Prequel or RelationKind.Sequel) + { + var reverseExists = relatedSeries.Relations.Any(r => + r.TargetSeriesId == series.Id && r.RelationKind == GetReverseRelation(relation.Relation)); + + if (!reverseExists) + { + var reverseRelation = new SeriesRelation + { + RelationKind = GetReverseRelation(relation.Relation), + TargetSeriesId = series.Id, + SeriesId = relatedSeries.Id, + }; + relatedSeries.Relations.Add(reverseRelation); + _unitOfWork.SeriesRepository.Attach(reverseRelation); + } + } + + _unitOfWork.SeriesRepository.Update(series); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + return true; + } + + private async Task UpdateCharacters(Series series, MetadataSettingsDto settings, IList? externalCharacters) + { + if (!settings.EnablePeople) return false; + + if (externalCharacters == null || externalCharacters.Count == 0) return false; + + if (series.Metadata.CharacterLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.Character)) + { + return false; + } + + series.Metadata.People ??= []; + + var characters = externalCharacters + .Select(w => new PersonDto() + { + Name = w.Name.Trim(), + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.Character) + // Need to ensure existing people are retained, but we overwrite anything from a bad match + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + if (characters.Count == 0) return false; + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); + + foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character)) + { + // Set a sort order based on their role + var characterMeta = externalCharacters.FirstOrDefault(c => c.Name == spPerson.Person.Name); + spPerson.OrderWeight = 0; + + if (characterMeta != null) + { + spPerson.KavitaPlusConnection = true; + + spPerson.OrderWeight = characterMeta.Role switch + { + CharacterRole.Main => 0, + CharacterRole.Supporting => 1, + CharacterRole.Background => 2, + _ => 99 // Default for unknown roles + }; + } + } + + // Download the image and save it + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + foreach (var character in externalCharacters) + { + var aniListId = ScrobblingService.ExtractId(character.Url, ScrobblingService.AniListCharacterWebsite); + if (aniListId <= 0) continue; + var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId); + if (person != null && !string.IsNullOrEmpty(character.ImageUrl) && string.IsNullOrEmpty(person.CoverImage)) + { + await _coverDbService.SetPersonCoverByUrl(person, character.ImageUrl, false); + } + } + + series.Metadata.AddKPlusOverride(MetadataSettingField.People); + return true; + } + + private async Task UpdateArtists(Series series, MetadataSettingsDto settings, List staff) + { + if (!settings.EnablePeople) return false; + + + var upstreamArtists = staff + .Where(s => s.Role is "Art" or "Story & Art") + .ToList(); + + if (upstreamArtists.Count == 0) return false; + + if (series.Metadata.CoverArtistLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.CoverArtist)) + { + return false; + } + + series.Metadata.People ??= []; + var artists = upstreamArtists + .Select(w => new PersonDto() + { + Name = w.Name.Trim(), + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.CoverArtist) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, artists, PersonRole.CoverArtist, _unitOfWork); + + foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist)) + { + var meta = upstreamArtists.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + await DownloadAndSetPersonCovers(upstreamArtists); + + series.Metadata.AddKPlusOverride(MetadataSettingField.People); + return true; + } + + private async Task UpdateWriters(Series series, MetadataSettingsDto settings, List staff) + { + if (!settings.EnablePeople) return false; + + var upstreamWriters = staff + .Where(s => s.Role is "Story" or "Story & Art") + .ToList(); + + if (upstreamWriters.Count == 0) return false; + + if (series.Metadata.WriterLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.Writer)) + { + return false; + } + + series.Metadata.People ??= []; + var writers = upstreamWriters + .Select(w => new PersonDto() + { + Name = w.Name.Trim(), + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.Writer) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork); + + foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.Writer)) + { + var meta = upstreamWriters.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + await DownloadAndSetPersonCovers(upstreamWriters); + series.Metadata.AddKPlusOverride(MetadataSettingField.People); + return true; + } + + private async Task UpdateTags(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedTags) + { + externalMetadata.Tags ??= []; + + if (!settings.EnableTags || processedTags.Count == 0) return false; + + if (series.Metadata.TagsLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Tags)) + { + return false; + } + + _logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name); + var madeModification = false; + var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize))) + .ToList(); + series.Metadata.Tags ??= []; + + TagHelper.UpdateTagList(processedTags, series, allTags, tag => + { + series.Metadata.Tags.Add(tag); + madeModification = true; + }, () => series.Metadata.TagsLocked = true); + + if (madeModification) + { + series.Metadata.AddKPlusOverride(MetadataSettingField.Tags); + } + + return madeModification; + } + + private static List ApplyBlackWhiteList(MetadataSettingsDto settings, MetadataFieldType fieldType, List processedStrings) + { + return fieldType switch + { + MetadataFieldType.Genre => processedStrings.Distinct() + .Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g)) + .ToList(), + MetadataFieldType.Tag => processedStrings.Distinct() + .Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g)) + .Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g)) + .ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(fieldType), fieldType, null) + }; + } + + private async Task UpdateGenres(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedGenres) + { + externalMetadata.Genres ??= []; + + if (!settings.EnableGenres || processedGenres.Count == 0) return false; + + if (series.Metadata.GenresLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Genres)) + { + return false; + } + + _logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name); + var madeModification = false; + var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList(); + series.Metadata.Genres ??= []; + var exisitingGenres = series.Metadata.Genres; + + GenreHelper.UpdateGenreList(processedGenres, series, allGenres, genre => + { + series.Metadata.Genres.Add(genre); + madeModification = true; + }, () => series.Metadata.GenresLocked = true); + + foreach (var genre in exisitingGenres) + { + if (series.Metadata.Genres.FirstOrDefault(g => g.NormalizedTitle == genre.NormalizedTitle) != null) continue; + series.Metadata.Genres.Add(genre); + madeModification = true; + } + + if (madeModification) + { + series.Metadata.AddKPlusOverride(MetadataSettingField.Genres); + } + + return madeModification; + } + + private async Task UpdatePublicationStatus(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnablePublicationStatus) return false; + + if (series.Metadata.PublicationStatusLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.PublicationStatus)) + { + return false; + } + + try + { + var chapters = + (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes + .SelectMany(v => v.Chapters).ToList(); + var status = DeterminePublicationStatus(series, chapters, externalMetadata); + + series.Metadata.PublicationStatus = status; + series.Metadata.PublicationStatusLocked = true; + series.Metadata.AddKPlusOverride(MetadataSettingField.PublicationStatus); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue determining Publication Status for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + + return false; + } + + private bool UpdateAgeRating(Series series, MetadataSettingsDto settings, IEnumerable allExternalTags) + { + + if (series.Metadata.AgeRatingLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.AgeRating)) + { + return false; + } + + try + { + // Determine Age Rating + var totalTags = allExternalTags + .Concat(series.Metadata.Genres.Select(g => g.Title)) + .Concat(series.Metadata.Tags.Select(g => g.Title)); + + var ageRating = DetermineAgeRating(totalTags, settings.AgeRatingMappings); + if (series.Metadata.AgeRating <= ageRating) + { + series.Metadata.AgeRating = ageRating; + series.Metadata.AddKPlusOverride(MetadataSettingField.AgeRating); + return true; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue determining Age Rating for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + + return false; + } + + + private async Task UpdateChapters(Series series, MetadataSettingsDto settings, + ExternalSeriesDetailDto externalMetadata) + { + if (externalMetadata.PlusMediaFormat != PlusMediaFormat.Comic) return false; + + if (externalMetadata.ChapterDtos == null || externalMetadata.ChapterDtos.Count == 0) return false; + + // Get all volumes and chapters + var madeModification = false; + var allChapters = await _unitOfWork.ChapterRepository.GetAllChaptersForSeries(series.Id); + + var matchedChapters = allChapters + .Join( + externalMetadata.ChapterDtos, + chapter => chapter.Range, + dto => dto.IssueNumber, + (chapter, dto) => (chapter, dto) // Create a tuple of matched pairs + ) + .ToList(); + + foreach (var (chapter, potentialMatch) in matchedChapters) + { + _logger.LogDebug("Updating {ChapterNumber} with metadata", chapter.Range); + + // Write the metadata + madeModification = UpdateChapterTitle(chapter, settings, potentialMatch.Title, series.Name) || madeModification; + madeModification = UpdateChapterSummary(chapter, settings, potentialMatch.Summary) || madeModification; + madeModification = UpdateChapterReleaseDate(chapter, settings, potentialMatch.ReleaseDate) || madeModification; + + var hasUpdatedPublisher = await UpdateChapterPublisher(chapter, settings, potentialMatch.Publisher); + if (hasUpdatedPublisher) chapter.AddKPlusOverride(MetadataSettingField.ChapterPublisher); + madeModification = hasUpdatedPublisher || madeModification; + + madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.CoverArtist, potentialMatch.Artists) || madeModification; + madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.Writer, potentialMatch.Writers) || madeModification; + + madeModification = await UpdateChapterCoverImage(chapter, settings, potentialMatch.CoverImageUrl) || madeModification; + madeModification = await UpdateExternalChapterMetadata(chapter, settings, potentialMatch) || madeModification; + + _unitOfWork.ChapterRepository.Update(chapter); + await _unitOfWork.CommitAsync(); + } + + return madeModification; + } + + private async Task UpdateExternalChapterMetadata(Chapter chapter, MetadataSettingsDto settings, ExternalChapterDto metadata) + { + if (!settings.Enabled) return false; + + if (metadata.UserReviews.Count == 0 && metadata.CriticReviews.Count == 0) + { + return false; + } + + var madeModification = false; + + #region Review + + // Remove existing Reviews + var existingReviews = await _unitOfWork.ChapterRepository.GetExternalChapterReview(chapter.Id); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(existingReviews); + + + List externalReviews = []; + externalReviews.AddRange(metadata.CriticReviews + .Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body)) + .Select(r => + { + var review = _mapper.Map(r); + review.ChapterId = chapter.Id; + review.Authority = RatingAuthority.Critic; + CleanCbrReview(ref review); + return review; + })); + externalReviews.AddRange(metadata.UserReviews + .Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body)) + .Select(r => + { + var review = _mapper.Map(r); + review.ChapterId = chapter.Id; + review.Authority = RatingAuthority.User; + CleanCbrReview(ref review); + return review; + })); + + chapter.ExternalReviews = externalReviews; + madeModification = externalReviews.Count > 0; + _logger.LogDebug("Added {Count} reviews for chapter {ChapterId}", externalReviews.Count, chapter.Id); + #endregion + + #region Rating + + // C# can't make the implicit conversation here + float? averageCriticRating = metadata.CriticReviews.Count > 0 ? metadata.CriticReviews.Average(r => r.Rating) : null; + float? averageUserRating = metadata.UserReviews.Count > 0 ? metadata.UserReviews.Average(r => r.Rating) : null; + + var existingRatings = await _unitOfWork.ChapterRepository.GetExternalChapterRatings(chapter.Id); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(existingRatings); + + chapter.ExternalRatings = []; + + if (averageUserRating != null) + { + chapter.ExternalRatings.Add(new ExternalRating + { + AverageScore = (int) averageUserRating, + Provider = ScrobbleProvider.Cbr, + Authority = RatingAuthority.User, + ProviderUrl = metadata.IssueUrl, + + }); + chapter.AverageExternalRating = averageUserRating.Value; + } + + if (averageCriticRating != null) + { + chapter.ExternalRatings.Add(new ExternalRating + { + AverageScore = (int) averageCriticRating, + Provider = ScrobbleProvider.Cbr, + Authority = RatingAuthority.Critic, + ProviderUrl = metadata.IssueUrl, + + }); + } + + madeModification = averageUserRating > 0f || averageCriticRating > 0f || madeModification; + + #endregion + + return madeModification; + } + + private static void CleanCbrReview(ref ExternalReview review) + { + // CBR has Read Full Review which links to site, but we already have that + review.Body = review.Body.Replace("Read Full Review", string.Empty).TrimEnd(); + review.RawBody = review.RawBody.Replace("Read Full Review", string.Empty).TrimEnd(); + review.BodyJustText = review.BodyJustText.Replace("Read Full Review", string.Empty).TrimEnd(); + } + + + private static bool UpdateChapterSummary(Chapter chapter, MetadataSettingsDto settings, string? summary) + { + if (!settings.EnableChapterSummary) return false; + + if (string.IsNullOrEmpty(summary)) return false; + + if (chapter.SummaryLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterSummary)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(summary) && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterSummary)) + { + return false; + } + + chapter.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(summary)); + chapter.AddKPlusOverride(MetadataSettingField.ChapterSummary); + return true; + } + + private static bool UpdateChapterTitle(Chapter chapter, MetadataSettingsDto settings, string? title, string seriesName) + { + if (!settings.EnableChapterTitle) return false; + + if (string.IsNullOrEmpty(title)) return false; + + if (chapter.TitleNameLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterTitle)) + { + return false; + } + + if (!title.Contains(seriesName) && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterTitle)) + { + return false; + } + + chapter.TitleName = title; + chapter.AddKPlusOverride(MetadataSettingField.ChapterTitle); + return true; + } + + private static bool UpdateChapterReleaseDate(Chapter chapter, MetadataSettingsDto settings, DateTime? releaseDate) + { + if (!settings.EnableChapterReleaseDate) return false; + + if (releaseDate == null || releaseDate == DateTime.MinValue) return false; + + if (chapter.ReleaseDateLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterReleaseDate)) + { + return false; + } + + if (!HasForceOverride(settings, chapter, MetadataSettingField.ChapterReleaseDate)) + { + return false; + } + + chapter.ReleaseDate = releaseDate.Value; + chapter.AddKPlusOverride(MetadataSettingField.ChapterReleaseDate); + return true; + } + + private async Task UpdateChapterPublisher(Chapter chapter, MetadataSettingsDto settings, string? publisher) + { + if (!settings.EnableChapterPublisher) return false; + + if (string.IsNullOrEmpty(publisher)) return false; + + if (chapter.PublisherLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterPublisher)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(publisher) && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterPublisher)) + { + return false; + } + + // Some publishers (CBR) can be represented as Boom! Studios/Boom! Town imprint, so let's handle that appropriately + if (publisher.Contains('/') || publisher.Contains("imprint", StringComparison.InvariantCultureIgnoreCase)) + { + var imprint = publisher.Split('/')[1].Replace("imprint", string.Empty); + return await UpdateChapterPeople(chapter, settings, PersonRole.Publisher, [publisher]) || + await UpdateChapterPeople(chapter, settings, PersonRole.Imprint, [imprint]); + } + + return await UpdateChapterPeople(chapter, settings, PersonRole.Publisher, [publisher]); + } + + private async Task UpdateChapterCoverImage(Chapter chapter, MetadataSettingsDto settings, string? coverUrl) + { + if (!settings.EnableChapterCoverImage) return false; + + if (string.IsNullOrEmpty(coverUrl)) return false; + + if (chapter.CoverImageLocked && !HasForceOverride(settings, chapter, MetadataSettingField.ChapterCovers)) + { + return false; + } + + if (string.IsNullOrEmpty(coverUrl)) + { + return false; + } + + await DownloadChapterCovers(chapter, coverUrl); + chapter.AddKPlusOverride(MetadataSettingField.ChapterCovers); + return true; + } + + private async Task UpdateChapterPeople(Chapter chapter, MetadataSettingsDto settings, PersonRole role, IList? staff) + { + if (!settings.EnablePeople) return false; + + if (staff?.Count == 0) return false; + + if (chapter.IsPersonRoleLocked(role) && !HasForceOverride(settings, chapter, MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(role) && role != PersonRole.Publisher) + { + return false; + } + + chapter.People ??= []; + var people = staff! + .Select(w => new PersonDto() + { + Name = w.Trim(), + }) + .Concat(chapter.People + .Where(p => p.Role == role) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + await PersonHelper.UpdateChapterPeopleAsync(chapter, staff ?? [], role, _unitOfWork); + + foreach (var person in chapter.People.Where(p => p.Role == role)) + { + var meta = people.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + _unitOfWork.ChapterRepository.Update(chapter); + await _unitOfWork.CommitAsync(); + + return true; + } + + private async Task UpdateCoverImage(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableCoverImage) return false; + + if (string.IsNullOrEmpty(externalMetadata.CoverUrl)) return false; + + if (series.CoverImageLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Covers)) + { + return false; + } + + if (string.IsNullOrEmpty(externalMetadata.CoverUrl)) + { + return false; + } + + await DownloadSeriesCovers(series, externalMetadata.CoverUrl); + series.Metadata.AddKPlusOverride(MetadataSettingField.Covers); + return true; + } + + + private static bool UpdateReleaseYear(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableStartDate) return false; + + if (!externalMetadata.StartDate.HasValue) return false; + + if (series.Metadata.ReleaseYearLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.StartDate)) + { + return false; + } + + if (series.Metadata.ReleaseYear != 0 && !HasForceOverride(settings, series.Metadata, MetadataSettingField.StartDate)) + { + return false; + } + + series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year; + series.Metadata.AddKPlusOverride(MetadataSettingField.StartDate); + return true; + } + + private static bool UpdateLocalizedName(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableLocalizedName) return false; + + if (series.LocalizedNameLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.LocalizedName)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(series.LocalizedName) && !HasForceOverride(settings, series.Metadata, MetadataSettingField.LocalizedName)) + { + return false; + } + + // We need to make the best appropriate guess + if (externalMetadata.Name == series.Name) + { + // Choose closest (usually last) synonym + var validSynonyms = externalMetadata.Synonyms + .Where(IsRomanCharacters) + .Where(s => s.ToNormalized() != series.Name.ToNormalized()) + .ToList(); + + if (validSynonyms.Count == 0) return false; + + series.LocalizedName = validSynonyms[^1]; + series.LocalizedNameLocked = true; + } + else if (IsRomanCharacters(externalMetadata.Name)) + { + series.LocalizedName = externalMetadata.Name; + series.LocalizedNameLocked = true; + } + + + series.Metadata.AddKPlusOverride(MetadataSettingField.LocalizedName); + return true; + } + + private static bool UpdateSummary(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableSummary) return false; + + if (string.IsNullOrEmpty(externalMetadata.Summary)) return false; + + if (series.Metadata.SummaryLocked && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Summary)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(series.Metadata.Summary) && !HasForceOverride(settings, series.Metadata, MetadataSettingField.Summary)) + { + return false; + } + + series.Metadata.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(externalMetadata.Summary)); + series.Metadata.AddKPlusOverride(MetadataSettingField.Summary); + return true; + } + + + private static RelationKind GetReverseRelation(RelationKind relation) + { + return relation switch + { + RelationKind.Prequel => RelationKind.Sequel, + RelationKind.Sequel => RelationKind.Prequel, + _ => relation // For other relationships, no reverse needed + }; + } + + private async Task DownloadSeriesCovers(Series series, string coverUrl) + { + try + { + // Only choose the better image if we're overriding a user provided cover + await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false, !series.Metadata.HasSetKPlusMetadata(MetadataSettingField.Covers)); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception downloading cover image for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + } + + private async Task DownloadChapterCovers(Chapter chapter, string coverUrl) + { + try + { + await _coverDbService.SetChapterCoverByUrl(chapter, coverUrl, false, true); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception downloading cover image for Chapter {ChapterName} ({SeriesId})", chapter.Range, chapter.Id); + } + } + + private async Task DownloadAndSetPersonCovers(List people) + { + foreach (var staff in people) + { + var aniListId = ScrobblingService.ExtractId(staff.Url, ScrobblingService.AniListStaffWebsite); + if (aniListId is null or <= 0) continue; + var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value); + if (person == null || string.IsNullOrEmpty(staff.ImageUrl) || + !string.IsNullOrEmpty(person.CoverImage) || staff.ImageUrl.EndsWith("default.jpg")) continue; + + try + { + await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false, true); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception saving cover image for Person {PersonName} ({PersonId})", person.Name, person.Id); + } + + } + } + + private PublicationStatus DeterminePublicationStatus(Series series, List chapters, ExternalSeriesDetailDto externalMetadata) + { + try + { + // Determine the expected total count based on local metadata + series.Metadata.TotalCount = Math.Max( + chapters.Max(chapter => chapter.TotalCount), + externalMetadata.Volumes > 0 ? externalMetadata.Volumes : externalMetadata.Chapters + ); + + // The actual number of count's defined across all chapter's metadata + series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count); + + var nonSpecialVolumes = series.Volumes + .Where(v => v.MaxNumber.IsNot(Parser.SpecialVolumeNumber)) + .ToList(); + + var maxVolume = (int)(nonSpecialVolumes.Count != 0 ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); + var maxChapter = (int)chapters.Max(c => c.MaxNumber); + + if (series.Format is MangaFormat.Epub or MangaFormat.Pdf && chapters.Count == 1) + { + series.Metadata.MaxCount = 1; + } + else if (series.Metadata.TotalCount <= 1 && chapters is [{ IsSpecial: true }]) + { + series.Metadata.MaxCount = series.Metadata.TotalCount; + } + else if ((maxChapter == Parser.DefaultChapterNumber || maxChapter > series.Metadata.TotalCount) && + maxVolume <= series.Metadata.TotalCount && maxVolume != Parser.DefaultChapterNumber) + { + series.Metadata.MaxCount = maxVolume; + } + else if (maxVolume == series.Metadata.TotalCount) + { + series.Metadata.MaxCount = maxVolume; + } + else + { + series.Metadata.MaxCount = maxChapter; + } + + var status = PublicationStatus.OnGoing; + + var hasExternalCounts = externalMetadata.Volumes > 0 || externalMetadata.Chapters > 0; + + if (hasExternalCounts) + { + status = PublicationStatus.Ended; + + if (IsSeriesCompleted(series, chapters, externalMetadata, maxVolume)) + { + status = PublicationStatus.Completed; + } + } + + return status; + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an issue determining Publication Status"); + } + + return PublicationStatus.OnGoing; + } + + /// + /// Returns true if the series should be marked as completed, checks loosey with chapter and series numbers. + /// Respects Specials to reach the required amount. + /// + /// + /// + /// + /// + /// + /// Updates MaxCount and TotalCount if a loosey check is used to set as completed + public static bool IsSeriesCompleted(Series series, List chapters, ExternalSeriesDetailDto externalMetadata, int maxVolumes) + { + // A series is completed if exactly the amount is found + if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0) + { + return true; + } + + // If volumes are collected, check if we reach the required volumes by including specials, and decimal volumes + // + // TODO BUG: If the series has specials, that are not included in the external count. But you do own them + // This may mark the series as completed pre-maturely + // Note: I've currently opted to keep this an equals to prevent the above bug from happening + // We *could* change this to >= in the future in case this is reported by users + // If we do; test IsSeriesCompleted_Volumes_TooManySpecials needs to be updated + if (maxVolumes != Parser.DefaultChapterNumber && externalMetadata.Volumes == series.Volumes.Count) + { + series.Metadata.MaxCount = series.Volumes.Count; + series.Metadata.TotalCount = series.Volumes.Count; + return true; + } + + // Note: If Kavita has specials, we should be lenient and ignore for the volume check + var volumeModifier = series.Volumes.Any(v => v.Name == Parser.SpecialVolume) ? 1 : 0; + var modifiedMinVolumeCount = series.Volumes.Count - volumeModifier; + if (maxVolumes != Parser.DefaultChapterNumber && externalMetadata.Volumes == modifiedMinVolumeCount) + { + series.Metadata.MaxCount = modifiedMinVolumeCount; + series.Metadata.TotalCount = modifiedMinVolumeCount; + return true; + } + + // If no volumes are collected, the series is completed if we reach or exceed the external chapters + if (maxVolumes == Parser.DefaultChapterNumber && series.Metadata.MaxCount >= externalMetadata.Chapters) + { + series.Metadata.TotalCount = series.Metadata.MaxCount; + return true; + } + + // If no volumes are collected, the series is complete if we reach or exceed the external chapters while including + // prologues, and extra chapters + if (maxVolumes == Parser.DefaultChapterNumber && chapters.Count >= externalMetadata.Chapters) + { + series.Metadata.TotalCount = chapters.Count; + series.Metadata.MaxCount = chapters.Count; + return true; + } + + + return false; + } + + private static Dictionary> ApplyFieldMappings(IEnumerable values, MetadataFieldType sourceType, List mappings) + { + var result = new Dictionary>(); + + foreach (var field in Enum.GetValues()) + { + result[field] = []; + } + + foreach (var value in values) + { + var mapping = mappings.FirstOrDefault(m => + m.SourceType == sourceType && + m.SourceValue.Equals(value, StringComparison.OrdinalIgnoreCase)); + + if (mapping != null && !string.IsNullOrWhiteSpace(mapping.DestinationValue)) + { + var targetType = mapping.DestinationType; + + if (!mapping.ExcludeFromSource) + { + result[sourceType].Add(mapping.SourceValue); + } + + result[targetType].Add(mapping.DestinationValue); + } + else + { + // If no mapping, keep the original value + result[sourceType].Add(value); + } + } + + // Ensure distinct + foreach (var key in result.Keys) + { + result[key] = result[key].Distinct().ToList(); + } + + return result; + } + + + /// + /// Returns the highest age rating from all tags/genres based on user-supplied mappings + /// + /// A combo of all tags/genres + /// + /// + public static AgeRating DetermineAgeRating(IEnumerable values, Dictionary mappings) + { + // Find highest age rating from mappings + mappings ??= new Dictionary(); + + return values + .Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown) + .DefaultIfEmpty(AgeRating.Unknown) + .Max(); + } + + + /// + /// Gets from DB or creates a new one with just SeriesId + /// + /// + /// + /// + private async Task GetOrCreateExternalSeriesMetadataForSeries(int seriesId, Series series) { var externalSeriesMetadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId); if (externalSeriesMetadata != null) return externalSeriesMetadata; @@ -360,6 +1785,7 @@ public class ExternalMetadataService : IExternalMetadataService }; series.ExternalSeriesMetadata = externalSeriesMetadata; _unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata); + return externalSeriesMetadata; } @@ -428,7 +1854,16 @@ public class ExternalMetadataService : IExternalMetadataService } - private async Task GetSeriesDetail(string license, int? aniListId, long? malId, int? seriesId) + /// + /// This is to get series information for the recommendation drawer on Kavita + /// + /// This uses a different API that series detail + /// + /// + /// + /// + /// + private async Task GetSeriesDetail(int? aniListId, long? malId, int? seriesId) { var payload = new ExternalMetadataIdsDto() { @@ -454,22 +1889,17 @@ public class ExternalMetadataService : IExternalMetadataService } payload.SeriesName = series.Name; payload.LocalizedSeriesName = series.LocalizedName; - payload.PlusMediaFormat = ConvertToMediaFormat(series.Library.Type, series.Format); + payload.PlusMediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format); } } try { - return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(payload) - .ReceiveJson(); + var ret = await _kavitaPlusApiService.GetSeriesDetailById(payload); + + ret.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(ret.Summary)); + + return ret; } catch (Exception e) @@ -480,15 +1910,9 @@ public class ExternalMetadataService : IExternalMetadataService return null; } - private static MediaFormat ConvertToMediaFormat(LibraryType libraryType, MangaFormat seriesFormat) + private static bool HasForceOverride(MetadataSettingsDto settings, IHasKPlusMetadata kPlusMetadata, + MetadataSettingField field) { - return libraryType switch - { - LibraryType.Manga => seriesFormat == MangaFormat.Epub ? MediaFormat.LightNovel : MediaFormat.Manga, - LibraryType.Comic => MediaFormat.Comic, - LibraryType.Book => MediaFormat.Book, - LibraryType.LightNovel => MediaFormat.LightNovel, - _ => MediaFormat.Unknown - }; + return settings.HasOverride(field) || kPlusMetadata.HasSetKPlusMetadata(field); } } diff --git a/API/Services/Plus/KavitaPlusApiService.cs b/API/Services/Plus/KavitaPlusApiService.cs new file mode 100644 index 000000000..ec4f414c3 --- /dev/null +++ b/API/Services/Plus/KavitaPlusApiService.cs @@ -0,0 +1,126 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Collection; +using API.DTOs.KavitaPlus.ExternalMetadata; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Metadata.Matching; +using API.DTOs.Scrobbling; +using API.Entities.Enums; +using API.Extensions; +using Flurl.Http; +using Kavita.Common; +using Microsoft.Extensions.Logging; + +namespace API.Services.Plus; + +/// +/// All Http requests to K+ should be contained in this service, the service will not handle any errors. +/// This is expected from the caller. +/// +public interface IKavitaPlusApiService +{ + Task HasTokenExpired(string license, string token, ScrobbleProvider provider); + Task GetRateLimit(string license, string token); + Task PostScrobbleUpdate(ScrobbleDto data, string license); + Task> GetMalStacks(string malUsername, string license); + Task> MatchSeries(MatchSeriesRequestDto request); + Task GetSeriesDetail(PlusSeriesRequestDto request); + Task GetSeriesDetailById(ExternalMetadataIdsDto request); +} + +public class KavitaPlusApiService(ILogger logger, IUnitOfWork unitOfWork): IKavitaPlusApiService +{ + private const string ScrobblingPath = "/api/scrobbling/"; + + public async Task HasTokenExpired(string license, string token, ScrobbleProvider provider) + { + var res = await Get(ScrobblingPath + "valid-key?provider=" + provider + "&key=" + token, license, token); + var str = await res.GetStringAsync(); + return bool.Parse(str); + } + + public async Task GetRateLimit(string license, string token) + { + var res = await Get(ScrobblingPath + "rate-limit?accessToken=" + token, license, token); + var str = await res.GetStringAsync(); + return int.Parse(str); + } + + public async Task PostScrobbleUpdate(ScrobbleDto data, string license) + { + return await PostAndReceive(ScrobblingPath + "update", data, license); + } + + public async Task> GetMalStacks(string malUsername, string license) + { + return await $"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={malUsername}" + .WithKavitaPlusHeaders(license) + .GetJsonAsync>(); + } + + public async Task> MatchSeries(MatchSeriesRequestDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/match-series") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson>(); + } + + public async Task GetSeriesDetail(PlusSeriesRequestDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson(); + } + + public async Task GetSeriesDetailById(ExternalMetadataIdsDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson(); + } + + /// + /// Send a GET request to K+ + /// + /// only path of the uri, the host is added + /// + /// + /// + private static async Task Get(string url, string license, string? aniListToken = null) + { + return await (Configuration.KavitaPlusApiUrl + url) + .WithKavitaPlusHeaders(license, aniListToken) + .GetAsync(); + } + + /// + /// Send a POST request to K+ + /// + /// only path of the uri, the host is added + /// + /// + /// + /// Return type + /// + private static async Task PostAndReceive(string url, object body, string license, string? aniListToken = null) + { + return await (Configuration.KavitaPlusApiUrl + url) + .WithKavitaPlusHeaders(license, aniListToken) + .PostJsonAsync(body) + .ReceiveJson(); + } +} diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index 5a12f7c0b..91f5a8fdd 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -1,10 +1,13 @@ using System; +using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; using API.DTOs.Account; -using API.DTOs.License; +using API.DTOs.KavitaPlus.License; using API.Entities.Enums; +using API.Extensions; +using API.Services.Tasks; using EasyCaching.Core; using Flurl.Http; using Kavita.Common; @@ -29,17 +32,23 @@ public interface ILicenseService Task HasActiveLicense(bool forceCheck = false); Task HasActiveSubscription(string? license); Task ResetLicense(string license, string email); + Task GetLicenseInfo(bool forceCheck = false); } public class LicenseService( IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork, - ILogger logger) + ILogger logger, + IVersionUpdaterService versionUpdaterService) : ILicenseService { private readonly TimeSpan _licenseCacheTimeout = TimeSpan.FromHours(8); - public const string Cron = "0 */4 * * *"; - private const string CacheKey = "license"; + public const string Cron = "0 */9 * * *"; + /// + /// Cache key for if license is valid or not + /// + public const string CacheKey = "license"; + private const string LicenseInfoCacheKey = "license-info"; /// @@ -53,13 +62,7 @@ public class LicenseService( try { var response = await (Configuration.KavitaPlusApiUrl + "/api/license/check") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(new LicenseValidDto() { License = license, @@ -87,13 +90,7 @@ public class LicenseService( try { var response = await (Configuration.KavitaPlusApiUrl + "/api/license/register") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(new EncryptLicenseDto() { License = license.Trim(), @@ -118,36 +115,6 @@ public class LicenseService( } } - /// - /// Checks licenses and updates cache - /// - /// Expected to be called at startup and on reoccurring basis - // public async Task ValidateLicenseStatus() - // { - // var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); - // try - // { - // var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - // if (string.IsNullOrEmpty(license.Value)) { - // await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); - // return; - // } - // - // _logger.LogInformation("Validating Kavita+ License"); - // - // await provider.FlushAsync(); - // var isValid = await IsLicenseValid(license.Value); - // await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout); - // - // _logger.LogInformation("Validating Kavita+ License - Complete"); - // } - // catch (Exception ex) - // { - // _logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins"); - // await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); - // BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30)); - // } - // } /// /// Checks licenses and updates cache @@ -163,22 +130,23 @@ public class LicenseService( if (cacheValue.HasValue) return cacheValue.Value; } + var result = false; try { var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - var result = await IsLicenseValid(serverSetting.Value); - await provider.FlushAsync(); - await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); - return result; + result = await IsLicenseValid(serverSetting.Value); } catch (Exception ex) { logger.LogError(ex, "There was an issue connecting to Kavita+"); + } + finally + { await provider.FlushAsync(); - await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); + await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); } - return false; + return result; } /// @@ -192,13 +160,7 @@ public class LicenseService( try { var response = await (Configuration.KavitaPlusApiUrl + "/api/license/check-sub") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(new LicenseValidDto() { License = license, @@ -230,6 +192,8 @@ public class LicenseService( var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); await provider.RemoveAsync(CacheKey); + + } public async Task AddLicense(string license, string email, string? discordId) @@ -251,13 +215,7 @@ public class LicenseService( { var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var response = await (Configuration.KavitaPlusApiUrl + "/api/license/reset") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", encryptedLicense.Value) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(encryptedLicense.Value) .PostJsonAsync(new ResetLicenseDto() { License = license.Trim(), @@ -283,4 +241,68 @@ public class LicenseService( return false; } + + /// + /// Fetches information about the license from Kavita+. If there is no license or an exception, will return null and can be assumed it is not active + /// + /// + /// + public async Task GetLicenseInfo(bool forceCheck = false) + { + // Check if there is a license + var hasLicense = + !string.IsNullOrEmpty((await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)) + .Value); + + if (!hasLicense) return null; + + // Check the cache + var licenseInfoProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.LicenseInfo); + if (!forceCheck) + { + var cacheValue = await licenseInfoProvider.GetAsync(LicenseInfoCacheKey); + if (cacheValue.HasValue) return cacheValue.Value; + } + + // TODO: If info.IsCancelled && notActive, let's remove the license so we aren't constantly checking + + try + { + var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); + var response = await (Configuration.KavitaPlusApiUrl + "/api/license/info") + .WithKavitaPlusHeaders(encryptedLicense.Value) + .GetJsonAsync(); + + // This indicates a mismatch on installId or no active subscription + if (response == null) return null; + + // Ensure that current version is within the 3 version limit. Don't count Nightly releases or Hotfixes + var releases = await versionUpdaterService.GetAllReleases(); + response.IsValidVersion = releases + .Where(r => !r.UpdateTitle.Contains("Hotfix")) // We don't care about Hotfix releases + .Where(r => !r.IsPrerelease) // Ensure we don't take current nightlies within the current/last stable + .Take(3) + .All(r => new Version(r.UpdateVersion) <= BuildInfo.Version); + + response.HasLicense = hasLicense; + + // Cache if the license is valid here as well + var licenseProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + await licenseProvider.SetAsync(CacheKey, response.IsActive, _licenseCacheTimeout); + + // Cache the license info if IsActive and ExpirationDate > DateTime.UtcNow + 2 + if (response.IsActive && response.ExpirationDate > DateTime.UtcNow.AddDays(2)) + { + await licenseInfoProvider.SetAsync(LicenseInfoCacheKey, response, _licenseCacheTimeout); + } + + return response; + } + catch (FlurlHttpException e) + { + logger.LogError(e, "An error happened during the request to Kavita+ API"); + } + + return null; + } } diff --git a/API/Services/Plus/RecommendationService.cs b/API/Services/Plus/RecommendationService.cs deleted file mode 100644 index 24cb1445b..000000000 --- a/API/Services/Plus/RecommendationService.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using API.Data; -using API.Data.Repositories; -using API.DTOs; -using API.DTOs.Recommendation; -using API.DTOs.Scrobbling; -using API.Entities; -using API.Entities.Enums; -using API.Extensions; -using API.Helpers.Builders; -using Flurl.Http; -using Kavita.Common; -using Kavita.Common.EnvironmentInfo; -using Kavita.Common.Helpers; -using Microsoft.Extensions.Logging; - -namespace API.Services.Plus; -#nullable enable - - -public interface IRecommendationService -{ - //Task GetRecommendationsForSeries(int userId, int seriesId); -} - - -public class RecommendationService : IRecommendationService -{ - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - - public RecommendationService(IUnitOfWork unitOfWork, ILogger logger) - { - _unitOfWork = unitOfWork; - _logger = logger; - - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - } - - public async Task GetRecommendationsForSeries(int userId, int seriesId) - { - var series = - await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, - SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Volumes | SeriesIncludes.Chapters); - if (series == null || series.Library.Type == LibraryType.Comic) return new RecommendationDto(); - var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - var canSeeExternalSeries = user is {AgeRestriction: AgeRating.NotApplicable} && - await _unitOfWork.UserRepository.IsUserAdminAsync(user); - - var recDto = new RecommendationDto() - { - ExternalSeries = new List(), - OwnedSeries = new List() - }; - - var recs = await GetRecommendations(license.Value, series); - foreach (var rec in recs) - { - // Find the series based on name and type and that the user has access too - var seriesForRec = await _unitOfWork.SeriesRepository.GetSeriesDtoByNamesAndMetadataIds(rec.RecommendationNames, - series.Library.Type, ScrobblingService.CreateUrl(ScrobblingService.AniListWeblinkWebsite, rec.AniListId), - ScrobblingService.CreateUrl(ScrobblingService.MalWeblinkWebsite, rec.MalId)); - - if (seriesForRec != null) - { - recDto.OwnedSeries.Add(seriesForRec); - continue; - } - - if (!canSeeExternalSeries) continue; - // We can show this based on user permissions - if (string.IsNullOrEmpty(rec.Name) || string.IsNullOrEmpty(rec.SiteUrl) || string.IsNullOrEmpty(rec.CoverUrl)) continue; - recDto.ExternalSeries.Add(new ExternalSeriesDto() - { - Name = string.IsNullOrEmpty(rec.Name) ? rec.RecommendationNames.First() : rec.Name, - Url = rec.SiteUrl, - CoverUrl = rec.CoverUrl, - Summary = rec.Summary, - AniListId = rec.AniListId, - MalId = rec.MalId - }); - } - - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, recDto.OwnedSeries); - - recDto.OwnedSeries = recDto.OwnedSeries.DistinctBy(s => s.Id).OrderBy(r => r.Name).ToList(); - recDto.ExternalSeries = recDto.ExternalSeries.DistinctBy(s => s.Name.ToNormalized()).OrderBy(r => r.Name).ToList(); - - return recDto; - } - - - protected async Task> GetRecommendations(string license, Series series) - { - try - { - return await (Configuration.KavitaPlusApiUrl + "/api/recommendation") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(new PlusSeriesDtoBuilder(series).Build()) - .ReceiveJson>(); - - } - catch (Exception e) - { - _logger.LogError(e, "An error happened during the request to Kavita+ API"); - } - - return new List(); - } -} diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 51939198b..f9c3fdb09 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading.Tasks; @@ -10,6 +11,7 @@ using API.DTOs.Filtering; using API.DTOs.Scrobbling; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Entities.Scrobble; using API.Extensions; using API.Helpers; @@ -18,8 +20,8 @@ using API.SignalR; using Flurl.Http; using Hangfire; using Kavita.Common; -using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Plus; @@ -36,27 +38,124 @@ public enum ScrobbleProvider Kavita = 0, AniList = 1, Mal = 2, + [Obsolete("No longer supported")] + GoogleBooks = 3, + Cbr = 4 } public interface IScrobblingService { + /// + /// An automated job that will run against all user's tokens and validate if they are still active + /// + /// This service can validate without license check as the task which calls will be guarded + /// Task CheckExternalAccessTokens(); + + /// + /// Checks if the token has expired with , if it has double checks with K+, + /// otherwise return false. + /// + /// + /// + /// + /// Returns true if there is no license present Task HasTokenExpired(int userId, ScrobbleProvider provider); + /// + /// Create, or update a non-processed, event, for the given series + /// + /// + /// + /// + /// Task ScrobbleRatingUpdate(int userId, int seriesId, float rating); + /// + /// NOP, until hardcover support has been worked out + /// + /// + /// + /// + /// + /// Task ScrobbleReviewUpdate(int userId, int seriesId, string? reviewTitle, string reviewBody); + /// + /// Create, or update a non-processed, event, for the given series + /// + /// + /// + /// Task ScrobbleReadingUpdate(int userId, int seriesId); + /// + /// Creates an or for + /// the given series + /// + /// + /// + /// + /// + /// Only the result of both WantToRead types is send to K+ Task ScrobbleWantToReadUpdate(int userId, int seriesId, bool onWantToRead); + /// + /// Removed all processed events that are at least 7 days old + /// + /// [DisableConcurrentExecution(60 * 60 * 60)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public Task ClearProcessedEvents(); + + /// + /// Makes K+ requests for all non-processed events until rate limits are reached + /// + /// [DisableConcurrentExecution(60 * 60 * 60)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ProcessUpdatesSinceLastSync(); + Task CreateEventsFromExistingHistory(int userId = 0); + Task CreateEventsFromExistingHistoryForSeries(int seriesId); Task ClearEventsForSeries(int userId, int seriesId); } +/// +/// Context used when syncing scrobble events. Do NOT reuse between syncs +/// +public class ScrobbleSyncContext +{ + public required List ReadEvents {get; init;} + public required List RatingEvents {get; init;} + /// Do not use this as events to send to K+, use + public required List AddToWantToRead {get; init;} + /// Do not use this as events to send to K+, use + public required List RemoveWantToRead {get; init;} + /// + /// Final events list if all AddTo- and RemoveWantToRead would be processed sequentially + /// + public required List Decisions {get; init;} + /// + /// K+ license + /// + public required string License { get; init; } + /// + /// Maps userId to left over request amount + /// + public required Dictionary RateLimits { get; init; } + + /// + /// All users being scrobbled for + /// + public List Users { get; set; } = []; + /// + /// Amount of already processed events + /// + public int ProgressCounter { get; set; } + + /// + /// Sum of all events to process + /// + public int TotalCount => ReadEvents.Count + RatingEvents.Count + AddToWantToRead.Count + RemoveWantToRead.Count; +} + public class ScrobblingService : IScrobblingService { private readonly IUnitOfWork _unitOfWork; @@ -64,53 +163,62 @@ public class ScrobblingService : IScrobblingService private readonly ILogger _logger; private readonly ILicenseService _licenseService; private readonly ILocalizationService _localizationService; + private readonly IEmailService _emailService; + private readonly IKavitaPlusApiService _kavitaPlusApiService; public const string AniListWeblinkWebsite = "https://anilist.co/manga/"; public const string MalWeblinkWebsite = "https://myanimelist.net/manga/"; public const string GoogleBooksWeblinkWebsite = "https://books.google.com/books?id="; public const string MangaDexWeblinkWebsite = "https://mangadex.org/title/"; + public const string AniListStaffWebsite = "https://anilist.co/staff/"; + public const string AniListCharacterWebsite = "https://anilist.co/character/"; - private static readonly IDictionary WeblinkExtractionMap = new Dictionary() + + private static readonly Dictionary WeblinkExtractionMap = new() { {AniListWeblinkWebsite, 0}, {MalWeblinkWebsite, 0}, {GoogleBooksWeblinkWebsite, 0}, {MangaDexWeblinkWebsite, 0}, + {AniListStaffWebsite, 0}, + {AniListCharacterWebsite, 0}, }; private const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90) - private static readonly IList BookProviders = new List() - { - }; - private static readonly IList LightNovelProviders = new List() - { + private static readonly IList BookProviders = []; + private static readonly IList LightNovelProviders = + [ ScrobbleProvider.AniList - }; - private static readonly IList ComicProviders = new List(); - private static readonly IList MangaProviders = new List() - { - ScrobbleProvider.AniList - }; + ]; + private static readonly IList ComicProviders = []; + private static readonly IList MangaProviders = (List) + [ScrobbleProvider.AniList]; private const string UnknownSeriesErrorMessage = "Series cannot be matched for Scrobbling"; private const string AccessTokenErrorMessage = "Access Token needs to be rotated to continue scrobbling"; + private const string InvalidKPlusLicenseErrorMessage = "Kavita+ subscription no longer active"; + private const string ReviewFailedErrorMessage = "Review was unable to be saved due to upstream requirements"; + private const string BadPayLoadErrorMessage = "Bad payload from Scrobble Provider"; public ScrobblingService(IUnitOfWork unitOfWork, IEventHub eventHub, ILogger logger, - ILicenseService licenseService, ILocalizationService localizationService) + ILicenseService licenseService, ILocalizationService localizationService, IEmailService emailService, + IKavitaPlusApiService kavitaPlusApiService) { _unitOfWork = unitOfWork; _eventHub = eventHub; _logger = logger; _licenseService = licenseService; _localizationService = localizationService; + _emailService = emailService; + _kavitaPlusApiService = kavitaPlusApiService; - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } + #region Access token checks /// /// An automated job that will run against all user's tokens and validate if they are still active @@ -123,13 +231,71 @@ public class ScrobblingService : IScrobblingService var users = await _unitOfWork.UserRepository.GetAllUsersAsync(); foreach (var user in users) { - if (string.IsNullOrEmpty(user.AniListAccessToken) || !TokenService.HasTokenExpired(user.AniListAccessToken)) continue; - _logger.LogInformation("User {UserName}'s AniList token has expired! They need to regenerate it for scrobbling to work", user.UserName); - await _eventHub.SendMessageToAsync(MessageFactory.ScrobblingKeyExpired, - MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), user.Id); + if (string.IsNullOrEmpty(user.AniListAccessToken)) continue; + + var tokenExpiry = JwtHelper.GetTokenExpiry(user.AniListAccessToken); + + // Send early reminder 5 days before token expiry + if (await ShouldSendEarlyReminder(user.Id, tokenExpiry)) + { + await _emailService.SendTokenExpiringSoonEmail(user.Id, ScrobbleProvider.AniList); + } + + // Send expiration notification after token expiry + if (await ShouldSendExpirationReminder(user.Id, tokenExpiry)) + { + await _emailService.SendTokenExpiredEmail(user.Id, ScrobbleProvider.AniList); + } + + // Check token validity + if (JwtHelper.IsTokenValid(user.AniListAccessToken)) continue; + + _logger.LogInformation( + "User {UserName}'s AniList token has expired or is expiring in a few days! They need to regenerate it for scrobbling to work", + user.UserName); + + // Notify user via event + await _eventHub.SendMessageToAsync( + MessageFactory.ScrobblingKeyExpired, + MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), + user.Id); + } } + /// + /// Checks if an early reminder email should be sent. + /// + private async Task ShouldSendEarlyReminder(int userId, DateTime tokenExpiry) + { + var earlyReminderDate = tokenExpiry.AddDays(-5); + if (earlyReminderDate > DateTime.UtcNow) return false; + + var hasAlreadySentReminder = await _unitOfWork.DataContext.EmailHistory + .AnyAsync(h => h.AppUserId == userId && h.Sent && + h.EmailTemplate == EmailService.TokenExpiringSoonTemplate && + h.SendDate >= earlyReminderDate); + + return !hasAlreadySentReminder; + + } + + /// + /// Checks if an expiration notification email should be sent. + /// + private async Task ShouldSendExpirationReminder(int userId, DateTime tokenExpiry) + { + if (tokenExpiry > DateTime.UtcNow) return false; + + var hasAlreadySentExpirationEmail = await _unitOfWork.DataContext.EmailHistory + .AnyAsync(h => h.AppUserId == userId && h.Sent && + h.EmailTemplate == EmailService.TokenExpirationTemplate && + h.SendDate >= tokenExpiry); + + return !hasAlreadySentExpirationEmail; + + } + public async Task HasTokenExpired(int userId, ScrobbleProvider provider) { var token = await GetTokenForProvider(userId, provider); @@ -147,25 +313,14 @@ public class ScrobblingService : IScrobblingService private async Task HasTokenExpired(string token, ScrobbleProvider provider) { - if (string.IsNullOrEmpty(token) || - !TokenService.HasTokenExpired(token)) return false; + if (string.IsNullOrEmpty(token) || !TokenService.HasTokenExpired(token)) return false; var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); if (string.IsNullOrEmpty(license.Value)) return true; try { - var response = await (Configuration.KavitaPlusApiUrl + "/api/scrobbling/valid-key?provider=" + provider + "&key=" + token) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license.Value) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .GetStringAsync(); - - return bool.Parse(response); + return await _kavitaPlusApiService.HasTokenExpired(license.Value, token, provider); } catch (HttpRequestException e) { @@ -191,59 +346,14 @@ public class ScrobblingService : IScrobblingService } ?? string.Empty; } - public async Task ScrobbleReviewUpdate(int userId, int seriesId, string? reviewTitle, string reviewBody) + #endregion + + #region Scrobble ingest + + public Task ScrobbleReviewUpdate(int userId, int seriesId, string? reviewTitle, string reviewBody) { // Currently disabled until at least hardcover is implemented - return; - if (!await _licenseService.HasActiveLicense()) return; - - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); - if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); - - _logger.LogInformation("Processing Scrobbling review event for {UserId} on {SeriesName}", userId, series.Name); - if (await CheckIfCannotScrobble(userId, seriesId, series)) return; - - if (IsAniListReviewValid(reviewTitle, reviewBody)) - { - _logger.LogDebug( - "Rejecting Scrobble event for {Series}. Review is not long enough to meet requirements", series.Name); - return; - } - - var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, - ScrobbleEventType.Review); - if (existingEvt is {IsProcessed: false}) - { - _logger.LogDebug("Overriding Review scrobble event for {Series}", existingEvt.Series.Name); - existingEvt.ReviewBody = reviewBody; - existingEvt.ReviewTitle = reviewTitle; - _unitOfWork.ScrobbleRepository.Update(existingEvt); - await _unitOfWork.CommitAsync(); - return; - } - - var evt = new ScrobbleEvent() - { - SeriesId = series.Id, - LibraryId = series.LibraryId, - ScrobbleEventType = ScrobbleEventType.Review, - AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = GetMalId(series), - AppUserId = userId, - Format = LibraryTypeHelper.GetFormat(series.Library.Type), - ReviewBody = reviewBody, - ReviewTitle = reviewTitle - }; - _unitOfWork.ScrobbleRepository.Attach(evt); - await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Review update on {SeriesName} with Userid {UserId} ", series.Name, userId); - } - - private static bool IsAniListReviewValid(string reviewTitle, string reviewBody) - { - return string.IsNullOrEmpty(reviewTitle) || string.IsNullOrEmpty(reviewBody) || (reviewTitle.Length < 2200 || - reviewTitle.Length > 120 || - reviewTitle.Length < 20); + return Task.CompletedTask; } public async Task ScrobbleRatingUpdate(int userId, int seriesId, float rating) @@ -253,11 +363,14 @@ public class ScrobblingService : IScrobblingService var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); - _logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; + + _logger.LogInformation("Processing Scrobbling rating event for {AppUserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, - ScrobbleEventType.ScoreUpdated); + ScrobbleEventType.ScoreUpdated, true); if (existingEvt is {IsProcessed: false}) { // We need to just update Volume/Chapter number @@ -277,24 +390,12 @@ public class ScrobblingService : IScrobblingService AniListId = GetAniListId(series), MalId = GetMalId(series), AppUserId = userId, - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), Rating = rating }; _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId}", series.Name, userId); - } - - private static long? GetMalId(Series series) - { - var malId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite); - return malId ?? series.ExternalSeriesMetadata.MalId; - } - - private static int? GetAniListId(Series series) - { - var aniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite); - return aniListId ?? series.ExternalSeriesMetadata.AniListId; + _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {AppUserId}", series.Name, userId); } public async Task ScrobbleReadingUpdate(int userId, int seriesId) @@ -304,31 +405,55 @@ public class ScrobblingService : IScrobblingService var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); - _logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; + + _logger.LogInformation("Processing Scrobbling reading event for {AppUserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; + var isAnyProgressOnSeries = await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId); + + var volumeNumber = (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId); + var chapterNumber = await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId); + + // Check if there is an existing not yet processed event, if so update it var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, - ScrobbleEventType.ChapterRead); + ScrobbleEventType.ChapterRead, true); + if (existingEvt is {IsProcessed: false}) { + if (!isAnyProgressOnSeries) + { + _unitOfWork.ScrobbleRepository.Remove(existingEvt); + await _unitOfWork.CommitAsync(); + _logger.LogDebug("Removed scrobble event for {Series} as there is no reading progress", series.Name); + return; + } + // We need to just update Volume/Chapter number var prevChapter = $"{existingEvt.ChapterNumber}"; var prevVol = $"{existingEvt.VolumeNumber}"; - existingEvt.VolumeNumber = - (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId); - existingEvt.ChapterNumber = - await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId); + existingEvt.VolumeNumber = volumeNumber; + existingEvt.ChapterNumber = chapterNumber; + _unitOfWork.ScrobbleRepository.Update(existingEvt); await _unitOfWork.CommitAsync(); + _logger.LogDebug("Overriding scrobble event for {Series} from vol {PrevVol} ch {PrevChap} -> vol {UpdatedVol} ch {UpdatedChap}", existingEvt.Series.Name, prevVol, prevChapter, existingEvt.VolumeNumber, existingEvt.ChapterNumber); return; } + if (!isAnyProgressOnSeries) + { + // Do not create a new scrobble event if there is no progress + return; + } + try { - var evt = new ScrobbleEvent() + var evt = new ScrobbleEvent { SeriesId = series.Id, LibraryId = series.LibraryId, @@ -336,16 +461,20 @@ public class ScrobblingService : IScrobblingService AniListId = GetAniListId(series), MalId = GetMalId(series), AppUserId = userId, - VolumeNumber = - (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId), - ChapterNumber = - await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId), - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + VolumeNumber = volumeNumber, + ChapterNumber = chapterNumber, + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), }; + if (evt.VolumeNumber is Parser.SpecialVolumeNumber) + { + // We don't process Specials because they will never match on AniList + return; + } + _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Read update on {SeriesName} with Userid {UserId} ", series.Name, userId); + _logger.LogDebug("Added Scrobbling Read update on {SeriesName} - Volume: {VolumeNumber} Chapter: {ChapterNumber} for User: {AppUserId}", series.Name, evt.VolumeNumber, evt.ChapterNumber, userId); } catch (Exception ex) { @@ -360,13 +489,22 @@ public class ScrobblingService : IScrobblingService var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); - _logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name); + if (!series.Library.AllowScrobbling) return; + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; + if (await CheckIfCannotScrobble(userId, seriesId, series)) return; + _logger.LogInformation("Processing Scrobbling want-to-read event for {AppUserId} on {SeriesName}", userId, series.Name); - var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id, - onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead); - if (existing) return; // BUG: If I take a series and add to remove from want to read, then add to want to read, Kavita rejects the second as a duplicate, when it's not + // Get existing events for this series/user + var existingEvents = (await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId)) + .Where(e => new[] { ScrobbleEventType.AddWantToRead, ScrobbleEventType.RemoveWantToRead }.Contains(e.ScrobbleEventType)); + // Remove all existing want-to-read events for this series/user + _unitOfWork.ScrobbleRepository.Remove(existingEvents); + + // Create the new event var evt = new ScrobbleEvent() { SeriesId = series.Id, @@ -375,552 +513,35 @@ public class ScrobblingService : IScrobblingService AniListId = GetAniListId(series), MalId = GetMalId(series), AppUserId = userId, - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), }; + _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {UserId} ", series.Name, userId); + _logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {AppUserId} ", series.Name, userId); } - private async Task CheckIfCannotScrobble(int userId, int seriesId, Series series) - { - if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) - { - _logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, - userId); - return true; - } + #endregion - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); - if (library is not {AllowScrobbling: true}) return true; - if (!ExternalMetadataService.IsPlusEligible(library.Type)) return true; - return false; + #region Scrobble provider methods + + private static bool IsAniListReviewValid(string reviewTitle, string reviewBody) + { + return string.IsNullOrEmpty(reviewTitle) || string.IsNullOrEmpty(reviewBody) || (reviewTitle.Length < 2200 || + reviewTitle.Length > 120 || + reviewTitle.Length < 20); } - private async Task GetRateLimit(string license, string aniListToken) + public static long? GetMalId(Series series) { - if (string.IsNullOrWhiteSpace(aniListToken)) return 0; - try - { - var response = await (Configuration.KavitaPlusApiUrl + "/api/scrobbling/rate-limit?accessToken=" + aniListToken) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .GetStringAsync(); - - return int.Parse(response); - } - catch (Exception e) - { - _logger.LogError(e, "An error happened during the request to Kavita+ API"); - } - - return 0; + var malId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite); + return malId ?? series.ExternalSeriesMetadata?.MalId; } - private async Task PostScrobbleUpdate(ScrobbleDto data, string license, ScrobbleEvent evt) + public static int? GetAniListId(Series seriesWithExternalMetadata) { - try - { - var response = await (Configuration.KavitaPlusApiUrl + "/api/scrobbling/update") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) - .PostJsonAsync(data) - .ReceiveJson(); - - if (!response.Successful) - { - // Might want to log this under ScrobbleError - if (response.ErrorMessage != null && response.ErrorMessage.Contains("Too Many Requests")) - { - _logger.LogInformation("Hit Too many requests, sleeping to regain requests and retrying"); - await Task.Delay(TimeSpan.FromMinutes(10)); - return await PostScrobbleUpdate(data, license, evt); - } - if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unauthorized")) - { - _logger.LogCritical("Kavita+ responded with Unauthorized. Please check your subscription"); - await _licenseService.HasActiveLicense(true); - evt.IsErrored = true; - evt.ErrorDetails = "Kavita+ subscription no longer active"; - throw new KavitaException("Kavita+ responded with Unauthorized. Please check your subscription"); - } - if (response.ErrorMessage != null && response.ErrorMessage.Contains("Access token is invalid")) - { - evt.IsErrored = true; - evt.ErrorDetails = AccessTokenErrorMessage; - throw new KavitaException("Access token is invalid"); - } - if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series")) - { - // Log the Series name and Id in ScrobbleErrors - _logger.LogInformation("Kavita+ was unable to match the series"); - if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) - { - _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() - { - Comment = UnknownSeriesErrorMessage, - Details = data.SeriesName, - LibraryId = evt.LibraryId, - SeriesId = evt.SeriesId - }); - await _unitOfWork.ExternalSeriesMetadataRepository.CreateBlacklistedSeries(evt.SeriesId, false); - } - - evt.IsErrored = true; - evt.ErrorDetails = UnknownSeriesErrorMessage; - } else if (response.ErrorMessage != null && response.ErrorMessage.StartsWith("Review")) - { - // Log the Series name and Id in ScrobbleErrors - _logger.LogInformation("Kavita+ was unable to save the review"); - if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) - { - _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() - { - Comment = response.ErrorMessage, - Details = data.SeriesName, - LibraryId = evt.LibraryId, - SeriesId = evt.SeriesId - }); - } - evt.IsErrored = true; - evt.ErrorDetails = "Review was unable to be saved due to upstream requirements"; - } - } - - return response.RateLeft; - } - catch (FlurlHttpException ex) - { - _logger.LogError("Scrobbling to Kavita+ API failed due to error: {ErrorMessage}", ex.Message); - if (ex.Message.Contains("Call failed with status code 500 (Internal Server Error)")) - { - if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) - { - _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() - { - Comment = UnknownSeriesErrorMessage, - Details = data.SeriesName, - LibraryId = evt.LibraryId, - SeriesId = evt.SeriesId - }); - } - evt.IsErrored = true; - evt.ErrorDetails = "Bad payload from Scrobble Provider"; - throw new KavitaException("Bad payload from Scrobble Provider"); - } - throw; - } - } - - /// - /// This will back fill events from existing progress history, ratings, and want to read for users that have a valid license - /// - /// Defaults to 0 meaning all users. Allows a userId to be set if a scrobble key is added to a user - public async Task CreateEventsFromExistingHistory(int userId = 0) - { - var libAllowsScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) - .ToDictionary(lib => lib.Id, lib => lib.AllowScrobbling); - - var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()) - .Where(l => userId == 0 || userId == l.Id) - .Select(u => u.Id); - - if (!await _licenseService.HasActiveLicense()) return; - - foreach (var uId in userIds) - { - var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId); - foreach (var wtr in wantToRead) - { - if (!libAllowsScrobbling[wtr.LibraryId]) continue; - await ScrobbleWantToReadUpdate(uId, wtr.Id, true); - } - - var ratings = await _unitOfWork.UserRepository.GetSeriesWithRatings(uId); - foreach (var rating in ratings) - { - if (!libAllowsScrobbling[rating.Series.LibraryId]) continue; - await ScrobbleRatingUpdate(uId, rating.SeriesId, rating.Rating); - } - - var reviews = await _unitOfWork.UserRepository.GetSeriesWithReviews(uId); - foreach (var review in reviews) - { - if (!libAllowsScrobbling[review.Series.LibraryId]) continue; - await ScrobbleReviewUpdate(uId, review.SeriesId, review.Tagline, review.Review); - } - - var seriesWithProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(0, uId, - new UserParams(), new FilterDto() - { - ReadStatus = new ReadStatus() - { - Read = true, - InProgress = true, - NotRead = false - }, - Libraries = libAllowsScrobbling.Keys.Where(k => libAllowsScrobbling[k]).ToList() - }); - - foreach (var series in seriesWithProgress) - { - if (!libAllowsScrobbling[series.LibraryId]) continue; - if (series.PagesRead <= 0) continue; // Since we only scrobble when things are higher, we can - await ScrobbleReadingUpdate(uId, series.Id); - } - - } - } - - /// - /// Removes all events (active) that are tied to a now-on hold series - /// - /// - /// - public async Task ClearEventsForSeries(int userId, int seriesId) - { - _logger.LogInformation("Clearing Pre-existing Scrobble events for Series {SeriesId} by User {UserId} as Series is now on hold list", seriesId, userId); - var events = await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId); - foreach (var scrobble in events) - { - _unitOfWork.ScrobbleRepository.Remove(scrobble); - } - - await _unitOfWork.CommitAsync(); - } - - /// - /// Removes all events that have been processed that are 7 days old - /// - [DisableConcurrentExecution(60 * 60 * 60)] - [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ClearProcessedEvents() - { - var events = await _unitOfWork.ScrobbleRepository.GetProcessedEvents(7); - _unitOfWork.ScrobbleRepository.Remove(events); - await _unitOfWork.CommitAsync(); - } - - /// - /// This is a task that is ran on a fixed schedule (every few hours or every day) that clears out the scrobble event table - /// and offloads the data to the API server which performs the syncing to the providers. - /// - [DisableConcurrentExecution(60 * 60 * 60)] - [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ProcessUpdatesSinceLastSync() - { - // Check how many scrobble events we have available then only do those. - _logger.LogInformation("Starting Scrobble Processing"); - var userRateLimits = new Dictionary(); - var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - - var progressCounter = 0; - - var librariesWithScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) - .AsEnumerable() - .Where(l => l.AllowScrobbling) - .Select(l => l.Id) - .ToImmutableHashSet(); - - var errors = (await _unitOfWork.ScrobbleRepository.GetScrobbleErrors()) - .Where(e => e.Comment == "Unknown Series" || e.Comment == UnknownSeriesErrorMessage || e.Comment == AccessTokenErrorMessage) - .Select(e => e.SeriesId) - .ToList(); - - var readEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.ChapterRead)) - .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) - .Where(e => !errors.Contains(e.SeriesId)) - .ToList(); - var addToWantToRead = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.AddWantToRead)) - .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) - .Where(e => !errors.Contains(e.SeriesId)) - .ToList(); - var removeWantToRead = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.RemoveWantToRead)) - .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) - .Where(e => !errors.Contains(e.SeriesId)) - .ToList(); - var ratingEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.ScoreUpdated)) - .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) - .Where(e => !errors.Contains(e.SeriesId)) - .ToList(); - - var decisions = addToWantToRead - .GroupBy(item => new { item.SeriesId, item.AppUserId }) - .Select(group => new - { - group.Key.SeriesId, - UserId = group.Key.AppUserId, - Event = group.First(), - Decision = group.Count() - removeWantToRead - .Count(removeItem => removeItem.SeriesId == group.Key.SeriesId && removeItem.AppUserId == group.Key.AppUserId) - }) - .Where(d => d.Decision > 0) - .Select(d => d.Event) - .ToList(); - - // For all userIds, ensure that we can connect and have access - var usersToScrobble = readEvents.Select(r => r.AppUser) - .Concat(addToWantToRead.Select(r => r.AppUser)) - .Concat(removeWantToRead.Select(r => r.AppUser)) - .Concat(ratingEvents.Select(r => r.AppUser)) - .Where(user => !string.IsNullOrEmpty(user.AniListAccessToken)) - .DistinctBy(u => u.Id) - .ToList(); - foreach (var user in usersToScrobble) - { - await SetAndCheckRateLimit(userRateLimits, user, license.Value); - } - - var totalProgress = readEvents.Count + decisions.Count + ratingEvents.Count + decisions.Count; - - _logger.LogInformation("Found {TotalEvents} Scrobble Events", totalProgress); - try - { - // Recalculate the highest volume/chapter - foreach (var readEvt in readEvents) - { - readEvt.VolumeNumber = - (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(readEvt.SeriesId, - readEvt.AppUser.Id); - readEvt.ChapterNumber = - await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(readEvt.SeriesId, - readEvt.AppUser.Id); - _unitOfWork.ScrobbleRepository.Update(readEvt); - } - progressCounter = await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, async evt => new ScrobbleDto() - { - Format = evt.Format, - AniListId = evt.AniListId, - MALId = (int?) evt.MalId, - ScrobbleEventType = evt.ScrobbleEventType, - ChapterNumber = evt.ChapterNumber, - VolumeNumber = (int?) evt.VolumeNumber, - AniListToken = evt.AppUser.AniListAccessToken, - SeriesName = evt.Series.Name, - LocalizedSeriesName = evt.Series.LocalizedName, - ScrobbleDateUtc = evt.LastModifiedUtc, - Year = evt.Series.Metadata.ReleaseYear, - StartedReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetFirstProgressForSeries(evt.SeriesId, evt.AppUser.Id), - LatestReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetLatestProgressForSeries(evt.SeriesId, evt.AppUser.Id), - }); - - progressCounter = await ProcessEvents(ratingEvents, userRateLimits, usersToScrobble.Count, progressCounter, - totalProgress, evt => Task.FromResult(new ScrobbleDto() - { - Format = evt.Format, - AniListId = evt.AniListId, - MALId = (int?) evt.MalId, - ScrobbleEventType = evt.ScrobbleEventType, - AniListToken = evt.AppUser.AniListAccessToken, - SeriesName = evt.Series.Name, - LocalizedSeriesName = evt.Series.LocalizedName, - Rating = evt.Rating, - Year = evt.Series.Metadata.ReleaseYear - })); - - progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter, - totalProgress, evt => Task.FromResult(new ScrobbleDto() - { - Format = evt.Format, - AniListId = evt.AniListId, - MALId = (int?) evt.MalId, - ScrobbleEventType = evt.ScrobbleEventType, - ChapterNumber = evt.ChapterNumber, - VolumeNumber = (int?) evt.VolumeNumber, - AniListToken = evt.AppUser.AniListAccessToken, - SeriesName = evt.Series.Name, - LocalizedSeriesName = evt.Series.LocalizedName, - Year = evt.Series.Metadata.ReleaseYear - })); - - // After decisions, we need to mark all the want to read and remove from want to read as completed - if (decisions.All(d => d.IsProcessed)) - { - foreach (var scrobbleEvent in addToWantToRead) - { - scrobbleEvent.IsProcessed = true; - scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; - _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); - } - foreach (var scrobbleEvent in removeWantToRead) - { - scrobbleEvent.IsProcessed = true; - scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; - _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); - } - await _unitOfWork.CommitAsync(); - } - } - catch (FlurlHttpException) - { - _logger.LogError("Kavita+ API or a Scrobble service may be experiencing an outage. Stopping sending data"); - return; - } - - - await SaveToDb(progressCounter, true); - _logger.LogInformation("Scrobbling Events is complete"); - } - - - private async Task ProcessEvents(IEnumerable events, IDictionary userRateLimits, - int usersToScrobble, int progressCounter, int totalProgress, Func> createEvent) - { - var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - foreach (var evt in events) - { - _logger.LogDebug("Processing Reading Events: {Count} / {Total}", progressCounter, totalProgress); - progressCounter++; - // Check if this media item can even be processed for this user - if (!DoesUserHaveProviderAndValid(evt)) - { - continue; - } - - if (TokenService.HasTokenExpired(evt.AppUser.AniListAccessToken)) - { - _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() - { - Comment = "AniList token has expired and needs rotating. Scrobbles wont work until then", - Details = $"User: {evt.AppUser.UserName}", - LibraryId = evt.LibraryId, - SeriesId = evt.SeriesId - }); - await _unitOfWork.CommitAsync(); - return 0; - } - - if (await _unitOfWork.ExternalSeriesMetadataRepository.IsBlacklistedSeries(evt.SeriesId)) - { - _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() - { - Comment = UnknownSeriesErrorMessage, - Details = $"User: {evt.AppUser.UserName} Series: {evt.Series.Name}", - LibraryId = evt.LibraryId, - SeriesId = evt.SeriesId - }); - evt.IsErrored = true; - evt.ErrorDetails = UnknownSeriesErrorMessage; - evt.ProcessDateUtc = DateTime.UtcNow; - _unitOfWork.ScrobbleRepository.Update(evt); - await _unitOfWork.CommitAsync(); - return 0; - } - - var count = await SetAndCheckRateLimit(userRateLimits, evt.AppUser, license.Value); - userRateLimits[evt.AppUserId] = count; - if (count == 0) - { - if (usersToScrobble == 1) break; - continue; - } - - try - { - var data = await createEvent(evt); - // We need to handle the encoding and changing it to the old one until we can update the API layer to handle these - // which could happen in v0.8.3 - if (data.VolumeNumber is Parser.SpecialVolumeNumber) - { - data.VolumeNumber = 0; - } - if (data.VolumeNumber is Parser.DefaultChapterNumber) - { - data.VolumeNumber = 0; - } - if (data.ChapterNumber is Parser.DefaultChapterNumber) - { - data.ChapterNumber = 0; - } - userRateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, license.Value, evt); - evt.IsProcessed = true; - evt.ProcessDateUtc = DateTime.UtcNow; - _unitOfWork.ScrobbleRepository.Update(evt); - } - catch (FlurlHttpException) - { - // If a flurl exception occured, the API is likely down. Kill processing - throw; - } - catch (KavitaException ex) - { - if (ex.Message.Contains("Access token is invalid")) - { - _logger.LogCritical("Access Token for UserId: {UserId} needs to be rotated to continue scrobbling", evt.AppUser.Id); - evt.IsErrored = true; - evt.ErrorDetails = AccessTokenErrorMessage; - _unitOfWork.ScrobbleRepository.Update(evt); - return progressCounter; - } - } - catch (Exception) - { - /* Swallow as it's already been handled in PostScrobbleUpdate */ - } - await SaveToDb(progressCounter); - // We can use count to determine how long to sleep based on rate gain. It might be specific to AniList, but we can model others - var delay = count > 10 ? TimeSpan.FromMilliseconds(ScrobbleSleepTime) : TimeSpan.FromSeconds(60); - await Task.Delay(delay); - } - - await SaveToDb(progressCounter, true); - return progressCounter; - } - - private async Task SaveToDb(int progressCounter, bool force = false) - { - if (!force || progressCounter % 5 == 0) - { - _logger.LogDebug("Saving Progress"); - await _unitOfWork.CommitAsync(); - } - } - - - private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent) - { - var userProviders = GetUserProviders(readEvent.AppUser); - if (readEvent.Series.Library.Type == LibraryType.Manga && MangaProviders.Intersect(userProviders).Any()) - { - return true; - } - - if (readEvent.Series.Library.Type == LibraryType.Comic && - ComicProviders.Intersect(userProviders).Any()) - { - return true; - } - - if (readEvent.Series.Library.Type == LibraryType.Book && - BookProviders.Intersect(userProviders).Any()) - { - return true; - } - - if (readEvent.Series.Library.Type == LibraryType.LightNovel && - LightNovelProviders.Intersect(userProviders).Any()) - { - return true; - } - - return false; - } - - private static IList GetUserProviders(AppUser appUser) - { - var providers = new List(); - if (!string.IsNullOrEmpty(appUser.AniListAccessToken)) providers.Add(ScrobbleProvider.AniList); - return providers; + var aniListId = ExtractId(seriesWithExternalMetadata.Metadata.WebLinks, AniListWeblinkWebsite); + return aniListId ?? seriesWithExternalMetadata.ExternalSeriesMetadata?.AniListId; } /// @@ -935,17 +556,23 @@ public class ScrobblingService : IScrobblingService foreach (var webLink in webLinks.Split(',')) { if (!webLink.StartsWith(website)) continue; + var tokens = webLink.Split(website)[1].Split('/'); var value = tokens[index]; + if (typeof(T) == typeof(int?)) { - if (int.TryParse(value, out var intValue)) - return (T)(object)intValue; + if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) return (T)(object)intValue; + } + else if (typeof(T) == typeof(int)) + { + if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) return (T)(object)intValue; + + return default; } else if (typeof(T) == typeof(long?)) { - if (long.TryParse(value, out var longValue)) - return (T)(object)longValue; + if (long.TryParse(value, CultureInfo.InvariantCulture, out var longValue)) return (T)(object)longValue; } else if (typeof(T) == typeof(string)) { @@ -953,7 +580,813 @@ public class ScrobblingService : IScrobblingService } } - return default(T?); + return default; + } + + /// + /// Generate a URL from a given ID and website + /// + /// Type of the ID (e.g., int, long, string) + /// The ID to embed in the URL + /// The base website URL + /// The generated URL or null if the website is not supported + public static string? GenerateUrl(T id, string website) + { + if (!WeblinkExtractionMap.ContainsKey(website)) + { + return null; // Unsupported website + } + + if (Equals(id, default(T))) + { + throw new ArgumentNullException(nameof(id), "ID cannot be null."); + } + + // Ensure the type of the ID matches supported types + if (typeof(T) == typeof(int) || typeof(T) == typeof(long) || typeof(T) == typeof(string)) + { + return $"{website}{id}"; + } + + throw new ArgumentException("Unsupported ID type. Supported types are int, long, and string.", nameof(id)); + } + + public static string CreateUrl(string url, long? id) + { + return id is null or 0 ? string.Empty : $"{url}{id}/"; + } + + #endregion + + /// + /// Returns false if, the series is on hold or Don't Match, or when the library has scrobbling disable or not eligible + /// + /// + /// + /// + /// + private async Task CheckIfCannotScrobble(int userId, int seriesId, Series series) + { + if (series.DontMatch) return true; + + if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) + { + _logger.LogInformation("Series {SeriesName} is on AppUserId {AppUserId}'s hold list. Not scrobbling", series.Name, userId); + return true; + } + + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); + if (library is not {AllowScrobbling: true} || !ExternalMetadataService.IsPlusEligible(library.Type)) return true; + + return false; + } + + /// + /// Returns the rate limit from the K+ api + /// + /// + /// + /// + private async Task GetRateLimit(string license, string aniListToken) + { + if (string.IsNullOrWhiteSpace(aniListToken)) return 0; + + try + { + return await _kavitaPlusApiService.GetRateLimit(license, aniListToken); + } + catch (Exception e) + { + _logger.LogError(e, "An error happened trying to get rate limit from Kavita+ API"); + } + + return 0; + } + + #region Scrobble process (Requests to K+) + + /// + /// Retrieve all events for which the series has not errored, then delete all current errors + /// + private async Task PrepareScrobbleContext() + { + var librariesWithScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) + .AsEnumerable() + .Where(l => l.AllowScrobbling) + .Select(l => l.Id) + .ToImmutableHashSet(); + + var erroredSeries = (await _unitOfWork.ScrobbleRepository.GetScrobbleErrors()) + .Where(e => e.Comment is "Unknown Series" or UnknownSeriesErrorMessage or AccessTokenErrorMessage) + .Select(e => e.SeriesId) + .ToList(); + + var readEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.ChapterRead)) + .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) + .Where(e => !erroredSeries.Contains(e.SeriesId)) + .ToList(); + var addToWantToRead = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.AddWantToRead)) + .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) + .Where(e => !erroredSeries.Contains(e.SeriesId)) + .ToList(); + var removeWantToRead = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.RemoveWantToRead)) + .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) + .Where(e => !erroredSeries.Contains(e.SeriesId)) + .ToList(); + var ratingEvents = (await _unitOfWork.ScrobbleRepository.GetByEvent(ScrobbleEventType.ScoreUpdated)) + .Where(e => librariesWithScrobbling.Contains(e.LibraryId)) + .Where(e => !erroredSeries.Contains(e.SeriesId)) + .ToList(); + + return new ScrobbleSyncContext + { + ReadEvents = readEvents, + RatingEvents = ratingEvents, + AddToWantToRead = addToWantToRead, + RemoveWantToRead = removeWantToRead, + Decisions = CalculateNetWantToReadDecisions(addToWantToRead, removeWantToRead), + RateLimits = [], + License = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value, + }; + } + + /// + /// Filters users who can scrobble, sets their rate limit and updates the + /// + /// + /// + private async Task PrepareUsersToScrobble(ScrobbleSyncContext ctx) + { + // For all userIds, ensure that we can connect and have access + var usersToScrobble = ctx.ReadEvents.Select(r => r.AppUser) + .Concat(ctx.AddToWantToRead.Select(r => r.AppUser)) + .Concat(ctx.RemoveWantToRead.Select(r => r.AppUser)) + .Concat(ctx.RatingEvents.Select(r => r.AppUser)) + .Where(user => !string.IsNullOrEmpty(user.AniListAccessToken)) + .Where(user => user.UserPreferences.AniListScrobblingEnabled) + .DistinctBy(u => u.Id) + .ToList(); + + foreach (var user in usersToScrobble) + { + await SetAndCheckRateLimit(ctx.RateLimits, user, ctx.License); + } + + ctx.Users = usersToScrobble; + } + + /// + /// Cleans up any events that are due to bugs or legacy + /// + private async Task CleanupOldOrBuggedEvents() + { + try + { + var eventsWithoutAnilistToken = (await _unitOfWork.ScrobbleRepository.GetEvents()) + .Where(e => e is { IsProcessed: false, IsErrored: false }) + .Where(e => string.IsNullOrEmpty(e.AppUser.AniListAccessToken)); + + _unitOfWork.ScrobbleRepository.Remove(eventsWithoutAnilistToken); + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when trying to delete old scrobble events when the user has no active token"); + } + } + + /// + /// This is a task that is run on a fixed schedule (every few hours or every day) that clears out the scrobble event table + /// and offloads the data to the API server which performs the syncing to the providers. + /// + [DisableConcurrentExecution(60 * 60 * 60)] + [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ProcessUpdatesSinceLastSync() + { + var ctx = await PrepareScrobbleContext(); + if (ctx.TotalCount == 0) return; + + // Get all the applicable users to scrobble and set their rate limits + await PrepareUsersToScrobble(ctx); + + _logger.LogInformation("Scrobble Processing Details:" + + "\n Read Events: {ReadEventsCount}" + + "\n Want to Read Events: {WantToReadEventsCount}" + + "\n Rating Events: {RatingEventsCount}" + + "\n Users to Scrobble: {UsersToScrobbleCount}" + + "\n Total Events to Process: {TotalEvents}", + ctx.ReadEvents.Count, + ctx.Decisions.Count, + ctx.RatingEvents.Count, + ctx.Users.Count, + ctx.TotalCount); + + try + { + await ProcessReadEvents(ctx); + await ProcessRatingEvents(ctx); + await ProcessWantToReadRatingEvents(ctx); + } + catch (FlurlHttpException ex) + { + _logger.LogError(ex, "Kavita+ API or a Scrobble service may be experiencing an outage. Stopping sending data"); + return; + } + + + await SaveToDb(ctx.ProgressCounter, true); + _logger.LogInformation("Scrobbling Events is complete"); + + await CleanupOldOrBuggedEvents(); + } + + /// + /// Calculates the net want-to-read decisions by considering all events. + /// Returns events that represent the final state for each user/series pair. + /// + /// List of events for adding to want-to-read + /// List of events for removing from want-to-read + /// List of events that represent the final state (add or remove) + private static List CalculateNetWantToReadDecisions(List addEvents, List removeEvents) + { + // Create a dictionary to track the latest event for each user/series combination + var latestEvents = new Dictionary<(int SeriesId, int AppUserId), ScrobbleEvent>(); + + // Process all add events + foreach (var addEvent in addEvents) + { + var key = (addEvent.SeriesId, addEvent.AppUserId); + + if (latestEvents.TryGetValue(key, out var value) && addEvent.CreatedUtc <= value.CreatedUtc) continue; + + value = addEvent; + latestEvents[key] = value; + } + + // Process all remove events + foreach (var removeEvent in removeEvents) + { + var key = (removeEvent.SeriesId, removeEvent.AppUserId); + + if (latestEvents.TryGetValue(key, out var value) && removeEvent.CreatedUtc <= value.CreatedUtc) continue; + + value = removeEvent; + latestEvents[key] = value; + } + + // Return all events that represent the final state + return latestEvents.Values.ToList(); + } + + private async Task ProcessWantToReadRatingEvents(ScrobbleSyncContext ctx) + { + await ProcessEvents(ctx.Decisions, ctx, evt => Task.FromResult(new ScrobbleDto + { + Format = evt.Format, + AniListId = evt.AniListId, + MALId = (int?) evt.MalId, + ScrobbleEventType = evt.ScrobbleEventType, + ChapterNumber = evt.ChapterNumber, + VolumeNumber = (int?) evt.VolumeNumber, + AniListToken = evt.AppUser.AniListAccessToken ?? string.Empty, + SeriesName = evt.Series.Name, + LocalizedSeriesName = evt.Series.LocalizedName, + Year = evt.Series.Metadata.ReleaseYear + })); + + // After decisions, we need to mark all the want to read and remove from want to read as completed + var processedDecisions = ctx.Decisions.Where(d => d.IsProcessed).ToList(); + if (processedDecisions.Count > 0) + { + foreach (var scrobbleEvent in processedDecisions) + { + scrobbleEvent.IsProcessed = true; + scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; + _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); + } + await _unitOfWork.CommitAsync(); + } + } + + private async Task ProcessRatingEvents(ScrobbleSyncContext ctx) + { + await ProcessEvents(ctx.RatingEvents, ctx, evt => Task.FromResult(new ScrobbleDto + { + Format = evt.Format, + AniListId = evt.AniListId, + MALId = (int?) evt.MalId, + ScrobbleEventType = evt.ScrobbleEventType, + AniListToken = evt.AppUser.AniListAccessToken ?? string.Empty, + SeriesName = evt.Series.Name, + LocalizedSeriesName = evt.Series.LocalizedName, + Rating = evt.Rating, + Year = evt.Series.Metadata.ReleaseYear + })); + } + + private async Task ProcessReadEvents(ScrobbleSyncContext ctx) + { + // Recalculate the highest volume/chapter + foreach (var readEvt in ctx.ReadEvents) + { + // Note: this causes skewing in the scrobble history because it makes it look like there are duplicate events + readEvt.VolumeNumber = + (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(readEvt.SeriesId, + readEvt.AppUser.Id); + readEvt.ChapterNumber = + await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(readEvt.SeriesId, + readEvt.AppUser.Id); + _unitOfWork.ScrobbleRepository.Update(readEvt); + } + + await ProcessEvents(ctx.ReadEvents, ctx, async evt => new ScrobbleDto + { + Format = evt.Format, + AniListId = evt.AniListId, + MALId = (int?) evt.MalId, + ScrobbleEventType = evt.ScrobbleEventType, + ChapterNumber = evt.ChapterNumber, + VolumeNumber = (int?) evt.VolumeNumber, + AniListToken = evt.AppUser.AniListAccessToken ?? string.Empty, + SeriesName = evt.Series.Name, + LocalizedSeriesName = evt.Series.LocalizedName, + ScrobbleDateUtc = evt.LastModifiedUtc, + Year = evt.Series.Metadata.ReleaseYear, + StartedReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetFirstProgressForSeries(evt.SeriesId, evt.AppUser.Id), + LatestReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetLatestProgressForSeries(evt.SeriesId, evt.AppUser.Id), + }); + } + + /// + /// Returns true if the user token is valid + /// + /// + /// + /// If the token is not, adds a scrobble error + private async Task ValidateUserToken(ScrobbleEvent evt) + { + if (!TokenService.HasTokenExpired(evt.AppUser.AniListAccessToken)) + return true; + + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError + { + Comment = "AniList token has expired and needs rotating. Scrobbling wont work until then", + Details = $"User: {evt.AppUser.UserName}, Expired: {TokenService.GetTokenExpiry(evt.AppUser.AniListAccessToken)}", + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + await _unitOfWork.CommitAsync(); + return false; + } + + /// + /// Returns true if the series can be scrobbled + /// + /// + /// + /// If the series cannot be scrobbled, adds a scrobble error + private async Task ValidateSeriesCanBeScrobbled(ScrobbleEvent evt) + { + if (evt.Series is { IsBlacklisted: false, DontMatch: false }) + return true; + + _logger.LogInformation("Series {SeriesName} ({SeriesId}) can't be matched and thus cannot scrobble this event", + evt.Series.Name, evt.SeriesId); + + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError + { + Comment = UnknownSeriesErrorMessage, + Details = $"User: {evt.AppUser.UserName} Series: {evt.Series.Name}", + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + + evt.SetErrorMessage(UnknownSeriesErrorMessage); + evt.ProcessDateUtc = DateTime.UtcNow; + _unitOfWork.ScrobbleRepository.Update(evt); + await _unitOfWork.CommitAsync(); + return false; + } + + /// + /// Removed Special parses numbers from chatter and volume numbers + /// + /// + /// + private static ScrobbleDto NormalizeScrobbleData(ScrobbleDto data) + { + // We need to handle the encoding and changing it to the old one until we can update the API layer to handle these + // which could happen in v0.8.3 + if (data.VolumeNumber is Parser.SpecialVolumeNumber or Parser.DefaultChapterNumber) + { + data.VolumeNumber = 0; + } + + + if (data.ChapterNumber is Parser.DefaultChapterNumber) + { + data.ChapterNumber = 0; + } + + + return data; + } + + /// + /// Loops through all events, and post them to K+ + /// + /// + /// + /// + private async Task ProcessEvents(IEnumerable events, ScrobbleSyncContext ctx, Func> createEvent) + { + foreach (var evt in events.Where(CanProcessScrobbleEvent)) + { + _logger.LogDebug("Processing Scrobble Events: {Count} / {Total}", ctx.ProgressCounter, ctx.TotalCount); + ctx.ProgressCounter++; + + if (!await ValidateUserToken(evt)) continue; + if (!await ValidateSeriesCanBeScrobbled(evt)) continue; + + var count = await SetAndCheckRateLimit(ctx.RateLimits, evt.AppUser, ctx.License); + if (count == 0) + { + if (ctx.Users.Count == 1) break; + continue; + } + + try + { + var data = NormalizeScrobbleData(await createEvent(evt)); + + ctx.RateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, ctx.License, evt); + + evt.IsProcessed = true; + evt.ProcessDateUtc = DateTime.UtcNow; + _unitOfWork.ScrobbleRepository.Update(evt); + } + catch (FlurlHttpException) + { + // If a flurl exception occured, the API is likely down. Kill processing + throw; + } + catch (KavitaException ex) + { + if (ex.Message.Contains("Access token is invalid")) + { + _logger.LogCritical(ex, "Access Token for AppUserId: {AppUserId} needs to be regenerated/renewed to continue scrobbling", evt.AppUser.Id); + evt.SetErrorMessage(AccessTokenErrorMessage); + _unitOfWork.ScrobbleRepository.Update(evt); + + // Ensure series with this error do not get re-processed next sync + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError + { + Comment = AccessTokenErrorMessage, + Details = $"{evt.AppUser.UserName} has an invalid access token (K+ Error)", + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId, + }); + } + } + catch (Exception ex) + { + /* Swallow as it's already been handled in PostScrobbleUpdate */ + _logger.LogError(ex, "Error processing event {EventId}", evt.Id); + } + + await SaveToDb(ctx.ProgressCounter); + + // We can use count to determine how long to sleep based on rate gain. It might be specific to AniList, but we can model others + var delay = count > 10 ? TimeSpan.FromMilliseconds(ScrobbleSleepTime) : TimeSpan.FromSeconds(60); + await Task.Delay(delay); + } + + await SaveToDb(ctx.ProgressCounter, true); + } + + /// + /// Save changes every five updates + /// + /// + /// Ignore update count check + private async Task SaveToDb(int progressCounter, bool force = false) + { + if ((force || progressCounter % 5 == 0) && _unitOfWork.HasChanges()) + { + _logger.LogDebug("Saving Scrobbling Event Processing Progress"); + await _unitOfWork.CommitAsync(); + } + } + + /// + /// If no errors have been logged for the given series, creates a new Unknown series error, and blacklists the series + /// + /// + /// + private async Task MarkSeriesAsUnknown(ScrobbleDto data, ScrobbleEvent evt) + { + if (await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) return; + + // Create a new ExternalMetadata entry to indicate that this is not matchable + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(evt.SeriesId, SeriesIncludes.ExternalMetadata); + if (series == null) return; + + series.ExternalSeriesMetadata ??= new ExternalSeriesMetadata {SeriesId = evt.SeriesId}; + series.IsBlacklisted = true; + _unitOfWork.SeriesRepository.Update(series); + + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError + { + Comment = UnknownSeriesErrorMessage, + Details = data.SeriesName, + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + } + + /// + /// Makes the K+ request, and handles any exceptions that occur + /// + /// Data to send to K+ + /// K+ license key + /// Related scrobble event + /// + /// Exceptions may be rethrown as a KavitaException + /// Some FlurlHttpException are also rethrown + public async Task PostScrobbleUpdate(ScrobbleDto data, string license, ScrobbleEvent evt) + { + try + { + var response = await _kavitaPlusApiService.PostScrobbleUpdate(data, license); + + _logger.LogDebug("K+ API Scrobble response for series {SeriesName}: Successful {Successful}, ErrorMessage {ErrorMessage}, ExtraInformation: {ExtraInformation}, RateLeft: {RateLeft}", + data.SeriesName, response.Successful, response.ErrorMessage, response.ExtraInformation, response.RateLeft); + + if (response.Successful || response.ErrorMessage == null) return response.RateLeft; + + // Might want to log this under ScrobbleError + if (response.ErrorMessage.Contains("Too Many Requests")) + { + _logger.LogInformation("Hit Too many requests while posting scrobble updates, sleeping to regain requests and retrying"); + await Task.Delay(TimeSpan.FromMinutes(10)); + return await PostScrobbleUpdate(data, license, evt); + } + + if (response.ErrorMessage.Contains("Unauthorized")) + { + _logger.LogCritical("Kavita+ responded with Unauthorized. Please check your subscription"); + await _licenseService.HasActiveLicense(true); + evt.SetErrorMessage(InvalidKPlusLicenseErrorMessage); + throw new KavitaException("Kavita+ responded with Unauthorized. Please check your subscription"); + } + + if (response.ErrorMessage.Contains("Access token is invalid")) + { + evt.SetErrorMessage(AccessTokenErrorMessage); + throw new KavitaException("Access token is invalid"); + } + + if (response.ErrorMessage.Contains("Unknown Series")) + { + // Log the Series name and Id in ScrobbleErrors + _logger.LogInformation("Kavita+ was unable to match the series: {SeriesName}", evt.Series.Name); + await MarkSeriesAsUnknown(data, evt); + evt.SetErrorMessage(UnknownSeriesErrorMessage); + } else if (response.ErrorMessage.StartsWith("Review")) + { + // Log the Series name and Id in ScrobbleErrors + _logger.LogInformation("Kavita+ was unable to save the review"); + if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) + { + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() + { + Comment = response.ErrorMessage, + Details = data.SeriesName, + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + } + evt.SetErrorMessage(ReviewFailedErrorMessage); + } + + return response.RateLeft; + } + catch (FlurlHttpException ex) + { + var errorMessage = await ex.GetResponseStringAsync(); + // Trim quotes if the response is a JSON string + errorMessage = errorMessage.Trim('"'); + + if (errorMessage.Contains("Too Many Requests")) + { + _logger.LogInformation("Hit Too many requests while posting scrobble updates, sleeping to regain requests and retrying"); + await Task.Delay(TimeSpan.FromMinutes(10)); + return await PostScrobbleUpdate(data, license, evt); + } + + _logger.LogError(ex, "Scrobbling to Kavita+ API failed due to error: {ErrorMessage}", ex.Message); + if (ex.StatusCode == 500 || ex.Message.Contains("Call failed with status code 500 (Internal Server Error)")) + { + if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) + { + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() + { + Comment = UnknownSeriesErrorMessage, + Details = data.SeriesName, + LibraryId = evt.LibraryId, + SeriesId = evt.SeriesId + }); + } + evt.SetErrorMessage(BadPayLoadErrorMessage); + throw new KavitaException(BadPayLoadErrorMessage); + } + throw; + } + } + + #endregion + + #region BackFill + + + /// + /// This will backfill events from existing progress history, ratings, and want to read for users that have a valid license + /// + /// Defaults to 0 meaning all users. Allows a userId to be set if a scrobble key is added to a user + public async Task CreateEventsFromExistingHistory(int userId = 0) + { + if (!await _licenseService.HasActiveLicense()) return; + + if (userId != 0) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null || string.IsNullOrEmpty(user.AniListAccessToken)) return; + if (user.HasRunScrobbleEventGeneration) + { + _logger.LogWarning("User {UserName} has already run scrobble event generation, Kavita will not generate more events", user.UserName); + return; + } + } + + var libAllowsScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) + .ToDictionary(lib => lib.Id, lib => lib.AllowScrobbling); + + var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()) + .Where(l => userId == 0 || userId == l.Id) + .Where(u => !u.HasRunScrobbleEventGeneration) + .Select(u => u.Id); + + foreach (var uId in userIds) + { + await CreateEventsFromExistingHistoryForUser(uId, libAllowsScrobbling); + } + } + + /// + /// Creates wantToRead, rating, reviews, and series progress events for the suer + /// + /// + /// + private async Task CreateEventsFromExistingHistoryForUser(int userId, Dictionary libAllowsScrobbling) + { + var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(userId); + foreach (var wtr in wantToRead) + { + if (!libAllowsScrobbling[wtr.LibraryId]) continue; + await ScrobbleWantToReadUpdate(userId, wtr.Id, true); + } + + var ratings = await _unitOfWork.UserRepository.GetSeriesWithRatings(userId); + foreach (var rating in ratings) + { + if (!libAllowsScrobbling[rating.Series.LibraryId]) continue; + await ScrobbleRatingUpdate(userId, rating.SeriesId, rating.Rating); + } + + var reviews = await _unitOfWork.UserRepository.GetSeriesWithReviews(userId); + foreach (var review in reviews.Where(r => !string.IsNullOrEmpty(r.Review))) + { + if (!libAllowsScrobbling[review.Series.LibraryId]) continue; + await ScrobbleReviewUpdate(userId, review.SeriesId, string.Empty, review.Review!); + } + + var seriesWithProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(0, userId, + new UserParams(), new FilterDto + { + ReadStatus = new ReadStatus + { + Read = true, + InProgress = true, + NotRead = false + }, + Libraries = libAllowsScrobbling.Keys.Where(k => libAllowsScrobbling[k]).ToList() + }); + + foreach (var series in seriesWithProgress.Where(series => series.PagesRead > 0)) + { + if (!libAllowsScrobbling[series.LibraryId]) continue; + await ScrobbleReadingUpdate(userId, series.Id); + } + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user != null) + { + user.HasRunScrobbleEventGeneration = true; + user.ScrobbleEventGenerationRan = DateTime.UtcNow; + await _unitOfWork.CommitAsync(); + } + } + + public async Task CreateEventsFromExistingHistoryForSeries(int seriesId) + { + if (!await _licenseService.HasActiveLicense()) return; + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null || !series.Library.AllowScrobbling) return; + + _logger.LogInformation("Creating Scrobbling events for Series {SeriesName}", series.Name); + + var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(u => u.Id); + + foreach (var uId in userIds) + { + // Handle "Want to Read" updates specific to the series + var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId); + foreach (var wtr in wantToRead.Where(wtr => wtr.Id == seriesId)) + { + await ScrobbleWantToReadUpdate(uId, wtr.Id, true); + } + + // Handle ratings specific to the series + var ratings = await _unitOfWork.UserRepository.GetSeriesWithRatings(uId); + foreach (var rating in ratings.Where(rating => rating.SeriesId == seriesId)) + { + await ScrobbleRatingUpdate(uId, rating.SeriesId, rating.Rating); + } + + // Handle review specific to the series + var reviews = await _unitOfWork.UserRepository.GetSeriesWithReviews(uId); + foreach (var review in reviews.Where(r => r.SeriesId == seriesId && !string.IsNullOrEmpty(r.Review))) + { + await ScrobbleReviewUpdate(uId, review.SeriesId, string.Empty, review.Review!); + } + + // Handle progress updates for the specific series + await ScrobbleReadingUpdate(uId, seriesId); + } + } + + #endregion + + /// + /// Removes all events (active) that are tied to a now-on hold series + /// + /// + /// + public async Task ClearEventsForSeries(int userId, int seriesId) + { + _logger.LogInformation("Clearing Pre-existing Scrobble events for Series {SeriesId} by User {AppUserId} as Series is now on hold list", seriesId, userId); + + var events = await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId); + _unitOfWork.ScrobbleRepository.Remove(events); + await _unitOfWork.CommitAsync(); + } + + /// + /// Removes all events that have been processed that are 7 days old + /// + [DisableConcurrentExecution(60 * 60 * 60)] + [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ClearProcessedEvents() + { + const int daysAgo = 7; + var events = await _unitOfWork.ScrobbleRepository.GetProcessedEvents(daysAgo); + _unitOfWork.ScrobbleRepository.Remove(events); + _logger.LogInformation("Removing {Count} scrobble events that have been processed {DaysAgo}+ days ago", events.Count, daysAgo); + await _unitOfWork.CommitAsync(); + } + + private static bool CanProcessScrobbleEvent(ScrobbleEvent readEvent) + { + var userProviders = GetUserProviders(readEvent.AppUser); + switch (readEvent.Series.Library.Type) + { + case LibraryType.Manga when MangaProviders.Intersect(userProviders).Any(): + case LibraryType.Comic when ComicProviders.Intersect(userProviders).Any(): + case LibraryType.Book when BookProviders.Intersect(userProviders).Any(): + case LibraryType.LightNovel when LightNovelProviders.Intersect(userProviders).Any(): + return true; + default: + return false; + } + } + + private static List GetUserProviders(AppUser appUser) + { + var providers = new List(); + if (!string.IsNullOrEmpty(appUser.AniListAccessToken)) providers.Add(ScrobbleProvider.AniList); + + return providers; } private async Task SetAndCheckRateLimit(IDictionary userRateLimits, AppUser user, string license) @@ -969,7 +1402,7 @@ public class ScrobblingService : IScrobblingService } catch (Exception ex) { - _logger.LogInformation("User {UserName} had an issue figuring out rate: {Message}", user.UserName, ex.Message); + _logger.LogInformation(ex, "User {UserName} had an issue figuring out rate: {Message}", user.UserName, ex.Message); userRateLimits.Add(user.Id, 0); } @@ -982,9 +1415,4 @@ public class ScrobblingService : IScrobblingService return count; } - public static string CreateUrl(string url, long? id) - { - if (id is null or 0) return string.Empty; - return $"{url}{id}/"; - } } diff --git a/API/Services/Plus/SmartCollectionSyncService.cs b/API/Services/Plus/SmartCollectionSyncService.cs index d5bbf2cce..1bd0dfb6b 100644 --- a/API/Services/Plus/SmartCollectionSyncService.cs +++ b/API/Services/Plus/SmartCollectionSyncService.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; +using API.DTOs.KavitaPlus.ExternalMetadata; using API.DTOs.Scrobbling; using API.Entities; using API.Entities.Enums; @@ -20,7 +21,7 @@ using Microsoft.Extensions.Logging; namespace API.Services.Plus; #nullable enable -sealed class SeriesCollection +internal sealed class SeriesCollection { public required IList Series { get; set; } public required string Summary { get; set; } @@ -158,7 +159,7 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService var normalizedLocalizedSeriesName = seriesInfo.LocalizedSeriesName?.ToNormalized(); // Search for existing series in the collection - var formats = GetMangaFormats(seriesInfo.PlusMediaFormat); + var formats = seriesInfo.PlusMediaFormat.GetMangaFormats(); var existingSeries = collection.Items.FirstOrDefault(s => (s.Name.ToNormalized() == normalizedSeriesName || s.NormalizedName == normalizedSeriesName || @@ -243,19 +244,7 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService } } - private static IList GetMangaFormats(MediaFormat? mediaFormat) - { - if (mediaFormat == null) return [MangaFormat.Archive]; - return mediaFormat switch - { - MediaFormat.Manga => [MangaFormat.Archive, MangaFormat.Image], - MediaFormat.Comic => [MangaFormat.Archive], - MediaFormat.LightNovel => [MangaFormat.Epub, MangaFormat.Pdf], - MediaFormat.Book => [MangaFormat.Epub, MangaFormat.Pdf], - MediaFormat.Unknown => [MangaFormat.Archive], - _ => [MangaFormat.Archive] - }; - } + private static long GetStackId(string url) { @@ -270,13 +259,7 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; var seriesForStack = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stack?stackId=" + stackId) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(license) .GetJsonAsync(); return seriesForStack; diff --git a/API/Services/Plus/WantToReadSyncService.cs b/API/Services/Plus/WantToReadSyncService.cs new file mode 100644 index 000000000..a6d536911 --- /dev/null +++ b/API/Services/Plus/WantToReadSyncService.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; +using API.DTOs.SeriesDetail; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using Flurl.Http; +using Hangfire; +using Kavita.Common; +using Microsoft.Extensions.Logging; +using Org.BouncyCastle.Bcpg.Sig; + +namespace API.Services.Plus; + + +public interface IWantToReadSyncService +{ + Task Sync(); +} + +/// +/// Responsible for syncing Want To Read from upstream providers with Kavita +/// +public class WantToReadSyncService : IWantToReadSyncService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly ILicenseService _licenseService; + + public WantToReadSyncService(IUnitOfWork unitOfWork, ILogger logger, ILicenseService licenseService) + { + _unitOfWork = unitOfWork; + _logger = logger; + _licenseService = licenseService; + } + + public async Task Sync() + { + if (!await _licenseService.HasActiveLicense()) return; + + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + + var users = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead | AppUserIncludes.UserPreferences); + foreach (var user in users.Where(u => u.UserPreferences.WantToReadSync)) + { + if (string.IsNullOrEmpty(user.MalUserName) && string.IsNullOrEmpty(user.AniListAccessToken)) continue; + + try + { + _logger.LogInformation("Syncing want to read for user: {UserName}", user.UserName); + var wantToReadSeries = + await ( + $"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/want-to-read?malUsername={user.MalUserName}&aniListToken={user.AniListAccessToken}") + .WithKavitaPlusHeaders(license) + .WithTimeout( + TimeSpan.FromSeconds(120)) // Give extra time as MAL + AniList can result in a lot of data + .GetJsonAsync>(); + + // Match the series (note: There may be duplicates in the final result) + foreach (var unmatchedSeries in wantToReadSeries) + { + var match = await _unitOfWork.SeriesRepository.MatchSeries(unmatchedSeries); + if (match == null) + { + continue; + } + + // There is a match, add it + user.WantToRead.Add(new AppUserWantToRead() + { + SeriesId = match.Id, + }); + _logger.LogDebug("Added {MatchName} ({Format}) to Want to Read", match.Name, match.Format); + } + + // Remove existing Want to Read that are duplicates + user.WantToRead = user.WantToRead.DistinctBy(d => d.SeriesId).ToList(); + + // TODO: Need to write in the history table the last sync time + + // Save the left over entities + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + // Trigger CleanupService to cleanup any series in WantToRead that don't belong + RecurringJob.TriggerJob(TaskScheduler.RemoveFromWantToReadTaskId); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when processing want to read series sync for {User}", user.UserName); + } + } + + } + + // Allow syncing if there are any libraries that have an appropriate Provider, the user has the appropriate token, and the last Sync validates + // private async Task CanSync(AppUser? user) + // { + // + // if (collection is not {Source: ScrobbleProvider.Mal}) return false; + // if (string.IsNullOrEmpty(collection.SourceUrl)) return false; + // if (collection.LastSyncUtc.Truncate(TimeSpan.TicksPerHour) >= DateTime.UtcNow.AddDays(SyncDelta).Truncate(TimeSpan.TicksPerHour)) return false; + // return true; + // } +} diff --git a/API/Services/RatingService.cs b/API/Services/RatingService.cs new file mode 100644 index 000000000..ccaebba69 --- /dev/null +++ b/API/Services/RatingService.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Entities; +using API.Services.Plus; +using Hangfire; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IRatingService +{ + /// + /// Updates the users' rating for a given series + /// + /// Should include ratings + /// + /// + Task UpdateSeriesRating(AppUser user, UpdateRatingDto updateRatingDto); + + /// + /// Updates the users' rating for a given chapter + /// + /// Should include ratings + /// chapterId must be set + /// + Task UpdateChapterRating(AppUser user, UpdateRatingDto updateRatingDto); +} + +public class RatingService: IRatingService +{ + + private readonly IUnitOfWork _unitOfWork; + private readonly IScrobblingService _scrobblingService; + private readonly ILogger _logger; + + public RatingService(IUnitOfWork unitOfWork, IScrobblingService scrobblingService, ILogger logger) + { + _unitOfWork = unitOfWork; + _scrobblingService = scrobblingService; + _logger = logger; + } + + public async Task UpdateSeriesRating(AppUser user, UpdateRatingDto updateRatingDto) + { + var userRating = + await _unitOfWork.UserRepository.GetUserRatingAsync(updateRatingDto.SeriesId, user.Id) ?? + new AppUserRating(); + + try + { + userRating.Rating = Math.Clamp(updateRatingDto.UserRating, 0f, 5f); + userRating.HasBeenRated = true; + userRating.SeriesId = updateRatingDto.SeriesId; + + if (userRating.Id == 0) + { + user.Ratings ??= new List(); + user.Ratings.Add(userRating); + } + + _unitOfWork.UserRepository.Update(user); + + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) + { + BackgroundJob.Enqueue(() => + _scrobblingService.ScrobbleRatingUpdate(user.Id, updateRatingDto.SeriesId, + userRating.Rating)); + return true; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception saving rating"); + } + + await _unitOfWork.RollbackAsync(); + user.Ratings?.Remove(userRating); + + return false; + } + + public async Task UpdateChapterRating(AppUser user, UpdateRatingDto updateRatingDto) + { + if (updateRatingDto.ChapterId == null) + { + return false; + } + + var userRating = + await _unitOfWork.UserRepository.GetUserChapterRatingAsync(user.Id, updateRatingDto.ChapterId.Value) ?? + new AppUserChapterRating(); + + try + { + userRating.Rating = Math.Clamp(updateRatingDto.UserRating, 0f, 5f); + userRating.HasBeenRated = true; + userRating.SeriesId = updateRatingDto.SeriesId; + userRating.ChapterId = updateRatingDto.ChapterId.Value; + + if (userRating.Id == 0) + { + user.ChapterRatings ??= new List(); + user.ChapterRatings.Add(userRating); + } + + _unitOfWork.UserRepository.Update(user); + + await _unitOfWork.CommitAsync(); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception saving rating"); + } + + await _unitOfWork.RollbackAsync(); + user.ChapterRatings?.Remove(userRating); + + return false; + } + +} diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index ca081bc9d..3b3cb37d5 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -122,6 +122,7 @@ public class ReaderService : IReaderService var seenVolume = new Dictionary(); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); if (series == null) throw new KavitaException("series-doesnt-exist"); + foreach (var chapter in chapters) { var userProgress = GetUserProgressForChapter(user, chapter); @@ -135,10 +136,6 @@ public class ReaderService : IReaderService SeriesId = seriesId, ChapterId = chapter.Id, LibraryId = series.LibraryId, - Created = DateTime.Now, - CreatedUtc = DateTime.UtcNow, - LastModified = DateTime.Now, - LastModifiedUtc = DateTime.UtcNow }); } else @@ -206,7 +203,7 @@ public class ReaderService : IReaderService /// Must have Progresses populated /// /// - private static AppUserProgress? GetUserProgressForChapter(AppUser user, Chapter chapter) + private AppUserProgress? GetUserProgressForChapter(AppUser user, Chapter chapter) { AppUserProgress? userProgress = null; @@ -226,11 +223,12 @@ public class ReaderService : IReaderService var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList(); if (progresses.Count > 1) { - user.Progresses = new List - { - user.Progresses.First() - }; + var highestProgress = progresses.Max(x => x.PagesRead); + var firstProgress = progresses.OrderBy(p => p.LastModifiedUtc).First(); + firstProgress.PagesRead = highestProgress; + user.Progresses = [firstProgress]; userProgress = user.Progresses.First(); + _logger.LogInformation("Trying to save progress and multiple progress entries exist, deleting and rewriting with highest progress rate: {@Progress}", userProgress); } } @@ -274,10 +272,6 @@ public class ReaderService : IReaderService ChapterId = progressDto.ChapterId, LibraryId = progressDto.LibraryId, BookScrollId = progressDto.BookScrollId, - Created = DateTime.Now, - CreatedUtc = DateTime.UtcNow, - LastModified = DateTime.Now, - LastModifiedUtc = DateTime.UtcNow }); _unitOfWork.UserRepository.Update(userWithProgress); } @@ -291,6 +285,7 @@ public class ReaderService : IReaderService _unitOfWork.AppUserProgressRepository.Update(userProgress); } + _logger.LogDebug("Saving Progress on Chapter {ChapterId} from Series {SeriesId} to {PageNum}", progressDto.ChapterId, progressDto.SeriesId, progressDto.PageNum); userProgress?.MarkModified(); if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 34360efa5..6ff8d19de 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -9,11 +9,10 @@ namespace API.Services; public interface IReadingItemService { - ComicInfo? GetComicInfo(string filePath); int GetNumberOfPages(string filePath, MangaFormat format); string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); - ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type); + ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata); } public class ReadingItemService : IReadingItemService @@ -51,9 +50,9 @@ public class ReadingItemService : IReadingItemService /// /// Fully qualified path of file /// - public ComicInfo? GetComicInfo(string filePath) + private ComicInfo? GetComicInfo(string filePath) { - if (Parser.IsEpub(filePath)) + if (Parser.IsEpub(filePath) || Parser.IsPdf(filePath)) { return _bookService.GetComicInfo(filePath); } @@ -72,11 +71,12 @@ public class ReadingItemService : IReadingItemService /// Path of a file /// /// Library type to determine parsing to perform - public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + /// Enable Metadata parsing overriding filename parsing + public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { try { - var info = Parse(path, rootPath, libraryRoot, type); + var info = Parse(path, rootPath, libraryRoot, type, enableMetadata); if (info == null) { _logger.LogError("Unable to parse any meaningful information out of file {FilePath}", path); @@ -100,6 +100,7 @@ public class ReadingItemService : IReadingItemService /// public int GetNumberOfPages(string filePath, MangaFormat format) { + switch (format) { case MangaFormat.Archive: @@ -174,28 +175,29 @@ public class ReadingItemService : IReadingItemService /// /// /// + /// /// - private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type) + private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { if (_comicVineParser.IsApplicable(path, type)) { - return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_imageParser.IsApplicable(path, type)) { - return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_bookParser.IsApplicable(path, type)) { - return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_pdfParser.IsApplicable(path, type)) { - return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_basicParser.IsApplicable(path, type)) { - return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } return null; diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index f56c268dd..8c4f63430 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -49,6 +50,14 @@ public interface IReadingListService Task CreateReadingListsFromSeries(int libraryId, int seriesId); Task GenerateReadingListCoverImage(int readingListId); + /// + /// Check, and update if needed, all reading lists' AgeRating who contain the passed series + /// + /// The series whose age rating is being updated + /// The new (uncommited) age rating of the series + /// + /// This method does not commit changes + Task UpdateReadingListAgeRatingForSeries(int seriesId, AgeRating ageRating); } /// @@ -95,7 +104,13 @@ public class ReadingListService : IReadingListService { title = $"Volume {Parser.CleanSpecialTitle(item.VolumeNumber)}"; } - } else { + } + else if (item.VolumeNumber == Parser.SpecialVolume) + { + title = specialTitle; + } + else + { title = $"Volume {specialTitle}"; } } @@ -107,15 +122,30 @@ public class ReadingListService : IReadingListService if (title != string.Empty) return title; + // item.ChapterNumber is Range if (item.ChapterNumber == Parser.DefaultChapter && !string.IsNullOrEmpty(item.ChapterTitleName)) { title = item.ChapterTitleName; } + else if (item.IsSpecial && + (!string.IsNullOrEmpty(item.ChapterTitleName) || !string.IsNullOrEmpty(chapterNum))) + { + if (!string.IsNullOrEmpty(item.ChapterTitleName)) + { + title = item.ChapterTitleName; + } + else + { + title = chapterNum; + } + + } else { title = ReaderService.FormatChapterName(item.LibraryType, true, true) + chapterNum; } + return title; } @@ -443,6 +473,7 @@ public class ReadingListService : IReadingListService _logger.LogInformation("Processing Reading Lists for {SeriesName}", series.Name); var user = await _unitOfWork.UserRepository.GetDefaultAdminUser(); series.Metadata ??= new SeriesMetadataBuilder().Build(); + foreach (var chapter in series.Volumes.SelectMany(v => v.Chapters)) { var pairs = new List>(); @@ -523,13 +554,13 @@ public class ReadingListService : IReadingListService var maxPairs = Math.Max(arcs.Length, arcNumbers.Length); for (var i = 0; i < maxPairs; i++) { - var arcNumber = int.MaxValue.ToString(); + var arcNumber = int.MaxValue.ToString(CultureInfo.InvariantCulture); if (arcNumbers.Length > i) { arcNumber = arcNumbers[i]; } - if (string.IsNullOrEmpty(arcs[i]) || !int.TryParse(arcNumber, out _)) continue; + if (string.IsNullOrEmpty(arcs[i]) || !int.TryParse(arcNumber, CultureInfo.InvariantCulture, out _)) continue; data.Add(new Tuple(arcs[i], arcNumber)); } @@ -548,14 +579,14 @@ public class ReadingListService : IReadingListService { CblName = cblReading.Name, Success = CblImportResult.Success, - Results = new List(), + Results = [], SuccessfulInserts = new List() }; if (IsCblEmpty(cblReading, importSummary, out var readingListFromCbl)) return readingListFromCbl; - // Is there another reading list with the same name? - if (await _unitOfWork.ReadingListRepository.ReadingListExists(cblReading.Name)) + // Is there another reading list with the same name on the user's account? + if (await _unitOfWork.ReadingListRepository.ReadingListExistsForUser(cblReading.Name, userId)) { importSummary.Success = CblImportResult.Fail; importSummary.Results.Add(new CblBookResult @@ -570,9 +601,6 @@ public class ReadingListService : IReadingListService var userSeries = (await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList(); - // How can we match properly with ComicVine library when year is part of the series unless we do this in 2 passes and see which has a better match - - if (userSeries.Count == 0) { // Report that no series exist in the reading list @@ -609,10 +637,6 @@ public class ReadingListService : IReadingListService private static List GetUniqueSeries(CblReadingList cblReading, bool useComicLibraryMatching) { - if (useComicLibraryMatching) - { - return cblReading.Books.Book.Select(b => Parser.Normalize(GetSeriesFormatting(b, useComicLibraryMatching))).Distinct().ToList(); - } return cblReading.Books.Book.Select(b => Parser.Normalize(GetSeriesFormatting(b, useComicLibraryMatching))).Distinct().ToList(); } @@ -832,4 +856,22 @@ public class ReadingListService : IReadingListService return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; } + + public async Task UpdateReadingListAgeRatingForSeries(int seriesId, AgeRating ageRating) + { + var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsBySeriesId(seriesId); + foreach (var readingList in readingLists) + { + var seriesIds = readingList.Items.Select(item => item.SeriesId).ToList(); + seriesIds.Remove(seriesId); // Don't get AgeRating from database + + var maxAgeRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds); + if (ageRating > maxAgeRating) + { + maxAgeRating = ageRating; + } + + readingList.AgeRating = maxAgeRating; + } + } } diff --git a/API/Services/ReadingProfileService.cs b/API/Services/ReadingProfileService.cs new file mode 100644 index 000000000..4c3dab006 --- /dev/null +++ b/API/Services/ReadingProfileService.cs @@ -0,0 +1,454 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Helpers.Builders; +using AutoMapper; +using Kavita.Common; + +namespace API.Services; +#nullable enable + +public interface IReadingProfileService +{ + /// + /// Returns the ReadingProfile that should be applied to the given series, walks up the tree. + /// Series (Implicit) -> Series (User) -> Library (User) -> Default + /// + /// + /// + /// + /// + Task GetReadingProfileDtoForSeries(int userId, int seriesId, bool skipImplicit = false); + + /// + /// Creates a new reading profile for a user. Name must be unique per user + /// + /// + /// + /// + Task CreateReadingProfile(int userId, UserReadingProfileDto dto); + Task PromoteImplicitProfile(int userId, int profileId); + + /// + /// Updates the implicit reading profile for a series, creates one if none exists + /// + /// + /// + /// + /// + Task UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto); + + /// + /// Updates the non-implicit reading profile for the given series, and removes implicit profiles + /// + /// + /// + /// + /// + Task UpdateParent(int userId, int seriesId, UserReadingProfileDto dto); + + /// + /// Updates a given reading profile for a user + /// + /// + /// + /// + /// Does not update connected series and libraries + Task UpdateReadingProfile(int userId, UserReadingProfileDto dto); + + /// + /// Deletes a given profile for a user + /// + /// + /// + /// + /// + /// The default profile for the user cannot be deleted + Task DeleteReadingProfile(int userId, int profileId); + + /// + /// Binds the reading profile to the series, and remove the implicit RP from the series if it exists + /// + /// + /// + /// + /// + Task AddProfileToSeries(int userId, int profileId, int seriesId); + /// + /// Binds the reading profile to many series, and remove the implicit RP from the series if it exists + /// + /// + /// + /// + /// + Task BulkAddProfileToSeries(int userId, int profileId, IList seriesIds); + /// + /// Remove all reading profiles bound to the series + /// + /// + /// + /// + Task ClearSeriesProfile(int userId, int seriesId); + + /// + /// Bind the reading profile to the library + /// + /// + /// + /// + /// + Task AddProfileToLibrary(int userId, int profileId, int libraryId); + /// + /// Remove the reading profile bound to the library, if it exists + /// + /// + /// + /// + Task ClearLibraryProfile(int userId, int libraryId); + /// + /// Returns the bound Reading Profile to a Library + /// + /// + /// + /// + Task GetReadingProfileDtoForLibrary(int userId, int libraryId); +} + +public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper): IReadingProfileService +{ + /// + /// Tries to resolve the Reading Profile for a given Series. Will first check (optionally) Implicit profiles, then check for a bound Series profile, then a bound + /// Library profile, then default to the default profile. + /// + /// + /// + /// + /// + /// + public async Task GetReadingProfileForSeries(int userId, int seriesId, bool skipImplicit = false) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, skipImplicit); + + // If there is an implicit, send back + var implicitProfile = + profiles.FirstOrDefault(p => p.SeriesIds.Contains(seriesId) && p.Kind == ReadingProfileKind.Implicit); + if (implicitProfile != null) return implicitProfile; + + // Next check for a bound Series profile + var seriesProfile = profiles + .FirstOrDefault(p => p.SeriesIds.Contains(seriesId) && p.Kind != ReadingProfileKind.Implicit); + if (seriesProfile != null) return seriesProfile; + + // Check for a library bound profile + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null) throw new KavitaException(await localizationService.Translate(userId, "series-doesnt-exist")); + + var libraryProfile = profiles + .FirstOrDefault(p => p.LibraryIds.Contains(series.LibraryId) && p.Kind != ReadingProfileKind.Implicit); + if (libraryProfile != null) return libraryProfile; + + // Fallback to the default profile + return profiles.First(p => p.Kind == ReadingProfileKind.Default); + } + + public async Task GetReadingProfileDtoForSeries(int userId, int seriesId, bool skipImplicit = false) + { + return mapper.Map(await GetReadingProfileForSeries(userId, seriesId, skipImplicit)); + } + + public async Task UpdateParent(int userId, int seriesId, UserReadingProfileDto dto) + { + var parentProfile = await GetReadingProfileForSeries(userId, seriesId, true); + + UpdateReaderProfileFields(parentProfile, dto, false); + unitOfWork.AppUserReadingProfileRepository.Update(parentProfile); + + // Remove the implicit profile when we UpdateParent (from reader) as it is implied that we are already bound with a non-implicit profile + await DeleteImplicateReadingProfilesForSeries(userId, [seriesId]); + + await unitOfWork.CommitAsync(); + return mapper.Map(parentProfile); + } + + public async Task UpdateReadingProfile(int userId, UserReadingProfileDto dto) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, dto.Id); + if (profile == null) throw new KavitaException("profile-does-not-exist"); + + UpdateReaderProfileFields(profile, dto); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + + await unitOfWork.CommitAsync(); + return mapper.Map(profile); + } + + public async Task CreateReadingProfile(int userId, UserReadingProfileDto dto) + { + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); + + if (await unitOfWork.AppUserReadingProfileRepository.IsProfileNameInUse(userId, dto.Name)) throw new KavitaException("name-already-in-use"); + + var newProfile = new AppUserReadingProfileBuilder(user.Id).Build(); + UpdateReaderProfileFields(newProfile, dto); + + unitOfWork.AppUserReadingProfileRepository.Add(newProfile); + user.ReadingProfiles.Add(newProfile); + + await unitOfWork.CommitAsync(); + + return mapper.Map(newProfile); + } + + /// + /// Promotes the implicit profile to a user profile. Removes the series from other profiles. + /// + /// + /// + /// + public async Task PromoteImplicitProfile(int userId, int profileId) + { + // Get all the user's profiles including the implicit + var allUserProfiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, false); + var profileToPromote = allUserProfiles.First(r => r.Id == profileId); + var seriesId = profileToPromote.SeriesIds[0]; // An Implicit series can only be bound to 1 Series + + // Check if there are any reading profiles (Series) already bound to the series + var existingSeriesProfile = allUserProfiles.FirstOrDefault(r => r.SeriesIds.Contains(seriesId) && r.Kind == ReadingProfileKind.User); + if (existingSeriesProfile != null) + { + existingSeriesProfile.SeriesIds.Remove(seriesId); + unitOfWork.AppUserReadingProfileRepository.Update(existingSeriesProfile); + } + + // Convert the implicit profile into a proper Series + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null) throw new KavitaException("series-doesnt-exist"); // Shouldn't happen + + profileToPromote.Kind = ReadingProfileKind.User; + profileToPromote.Name = await localizationService.Translate(userId, "generated-reading-profile-name", series.Name); + profileToPromote.Name = EnsureUniqueProfileName(allUserProfiles, profileToPromote.Name); + profileToPromote.NormalizedName = profileToPromote.Name.ToNormalized(); + unitOfWork.AppUserReadingProfileRepository.Update(profileToPromote); + + await unitOfWork.CommitAsync(); + + return mapper.Map(profileToPromote); + } + + private static string EnsureUniqueProfileName(IList allUserProfiles, string name) + { + var counter = 1; + var newName = name; + while (allUserProfiles.Any(p => p.Name == newName)) + { + newName = $"{name} ({counter})"; + counter++; + } + + return newName; + } + + public async Task UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto) + { + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); + + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var existingProfile = profiles.FirstOrDefault(rp => rp.Kind == ReadingProfileKind.Implicit && rp.SeriesIds.Contains(seriesId)); + + // Series already had an implicit profile, update it + if (existingProfile is {Kind: ReadingProfileKind.Implicit}) + { + UpdateReaderProfileFields(existingProfile, dto, false); + unitOfWork.AppUserReadingProfileRepository.Update(existingProfile); + await unitOfWork.CommitAsync(); + + return mapper.Map(existingProfile); + } + + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId) ?? throw new KeyNotFoundException(); + var newProfile = new AppUserReadingProfileBuilder(userId) + .WithSeries(series) + .WithKind(ReadingProfileKind.Implicit) + .Build(); + + // Set name to something fitting for debugging if needed + UpdateReaderProfileFields(newProfile, dto, false); + newProfile.Name = $"Implicit Profile for {seriesId}"; + newProfile.NormalizedName = newProfile.Name.ToNormalized(); + + user.ReadingProfiles.Add(newProfile); + await unitOfWork.CommitAsync(); + + return mapper.Map(newProfile); + } + + public async Task DeleteReadingProfile(int userId, int profileId) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + if (profile.Kind == ReadingProfileKind.Default) throw new KavitaException("cant-delete-default-profile"); + + unitOfWork.AppUserReadingProfileRepository.Remove(profile); + await unitOfWork.CommitAsync(); + } + + public async Task AddProfileToSeries(int userId, int profileId, int seriesId) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + await DeleteImplicitAndRemoveFromUserProfiles(userId, [seriesId], []); + + profile.SeriesIds.Add(seriesId); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + + await unitOfWork.CommitAsync(); + } + + public async Task BulkAddProfileToSeries(int userId, int profileId, IList seriesIds) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + await DeleteImplicitAndRemoveFromUserProfiles(userId, seriesIds, []); + + profile.SeriesIds.AddRange(seriesIds.Except(profile.SeriesIds)); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + + await unitOfWork.CommitAsync(); + } + + public async Task ClearSeriesProfile(int userId, int seriesId) + { + await DeleteImplicitAndRemoveFromUserProfiles(userId, [seriesId], []); + await unitOfWork.CommitAsync(); + } + + public async Task AddProfileToLibrary(int userId, int profileId, int libraryId) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + await DeleteImplicitAndRemoveFromUserProfiles(userId, [], [libraryId]); + + profile.LibraryIds.Add(libraryId); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + await unitOfWork.CommitAsync(); + } + + public async Task ClearLibraryProfile(int userId, int libraryId) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var libraryProfile = profiles.FirstOrDefault(p => p.LibraryIds.Contains(libraryId)); + if (libraryProfile != null) + { + libraryProfile.LibraryIds.Remove(libraryId); + unitOfWork.AppUserReadingProfileRepository.Update(libraryProfile); + } + + + if (unitOfWork.HasChanges()) + { + await unitOfWork.CommitAsync(); + } + } + + public async Task GetReadingProfileDtoForLibrary(int userId, int libraryId) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, true); + return mapper.Map(profiles.FirstOrDefault(p => p.LibraryIds.Contains(libraryId))); + } + + private async Task DeleteImplicitAndRemoveFromUserProfiles(int userId, IList seriesIds, IList libraryIds) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var implicitProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any()) + .Where(rp => rp.Kind == ReadingProfileKind.Implicit) + .ToList(); + unitOfWork.AppUserReadingProfileRepository.RemoveRange(implicitProfiles); + + var nonImplicitProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any() || rp.LibraryIds.Intersect(libraryIds).Any()) + .Where(rp => rp.Kind != ReadingProfileKind.Implicit); + + foreach (var profile in nonImplicitProfiles) + { + profile.SeriesIds.RemoveAll(seriesIds.Contains); + profile.LibraryIds.RemoveAll(libraryIds.Contains); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + } + } + + private async Task DeleteImplicateReadingProfilesForSeries(int userId, IList seriesIds) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var implicitProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any()) + .Where(rp => rp.Kind == ReadingProfileKind.Implicit) + .ToList(); + unitOfWork.AppUserReadingProfileRepository.RemoveRange(implicitProfiles); + } + + private async Task RemoveSeriesFromUserProfiles(int userId, IList seriesIds) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var userProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any()) + .Where(rp => rp.Kind == ReadingProfileKind.User) + .ToList(); + + unitOfWork.AppUserReadingProfileRepository.RemoveRange(userProfiles); + } + + public static void UpdateReaderProfileFields(AppUserReadingProfile existingProfile, UserReadingProfileDto dto, bool updateName = true) + { + if (updateName && !string.IsNullOrEmpty(dto.Name) && existingProfile.NormalizedName != dto.Name.ToNormalized()) + { + existingProfile.Name = dto.Name; + existingProfile.NormalizedName = dto.Name.ToNormalized(); + } + + // Manga Reader + existingProfile.ReadingDirection = dto.ReadingDirection; + existingProfile.ScalingOption = dto.ScalingOption; + existingProfile.PageSplitOption = dto.PageSplitOption; + existingProfile.ReaderMode = dto.ReaderMode; + existingProfile.AutoCloseMenu = dto.AutoCloseMenu; + existingProfile.ShowScreenHints = dto.ShowScreenHints; + existingProfile.EmulateBook = dto.EmulateBook; + existingProfile.LayoutMode = dto.LayoutMode; + existingProfile.BackgroundColor = string.IsNullOrEmpty(dto.BackgroundColor) ? "#000000" : dto.BackgroundColor; + existingProfile.SwipeToPaginate = dto.SwipeToPaginate; + existingProfile.AllowAutomaticWebtoonReaderDetection = dto.AllowAutomaticWebtoonReaderDetection; + existingProfile.WidthOverride = dto.WidthOverride; + existingProfile.DisableWidthOverride = dto.DisableWidthOverride; + + // Book Reader + existingProfile.BookReaderMargin = dto.BookReaderMargin; + existingProfile.BookReaderLineSpacing = dto.BookReaderLineSpacing; + existingProfile.BookReaderFontSize = dto.BookReaderFontSize; + existingProfile.BookReaderFontFamily = dto.BookReaderFontFamily; + existingProfile.BookReaderTapToPaginate = dto.BookReaderTapToPaginate; + existingProfile.BookReaderReadingDirection = dto.BookReaderReadingDirection; + existingProfile.BookReaderWritingStyle = dto.BookReaderWritingStyle; + existingProfile.BookThemeName = dto.BookReaderThemeName; + existingProfile.BookReaderLayoutMode = dto.BookReaderLayoutMode; + existingProfile.BookReaderImmersiveMode = dto.BookReaderImmersiveMode; + + // PDF Reading + existingProfile.PdfTheme = dto.PdfTheme; + existingProfile.PdfScrollMode = dto.PdfScrollMode; + existingProfile.PdfSpreadMode = dto.PdfSpreadMode; + } +} diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 9140d26e6..78e3c41f1 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -1,27 +1,26 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; -using API.Constants; -using API.Controllers; using API.Data; using API.Data.Repositories; using API.DTOs; -using API.DTOs.CollectionTags; +using API.DTOs.Person; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; using API.SignalR; -using EasyCaching.Core; using Hangfire; using Kavita.Common; using Microsoft.Extensions.Logging; @@ -33,17 +32,16 @@ public interface ISeriesService { Task GetSeriesDetail(int seriesId, int userId); Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto); - Task UpdateRating(AppUser user, UpdateSeriesRatingDto updateSeriesRatingDto); Task DeleteMultipleSeries(IList seriesIds); Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto); Task GetRelatedSeries(int userId, int seriesId); Task FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true); Task FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true); - Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle, bool withHash); Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false); Task GetEstimatedChapterCreationDate(int seriesId, int userId); + } public class SeriesService : ISeriesService @@ -54,6 +52,7 @@ public class SeriesService : ISeriesService private readonly ILogger _logger; private readonly IScrobblingService _scrobblingService; private readonly ILocalizationService _localizationService; + private readonly IReadingListService _readingListService; private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto { @@ -63,7 +62,8 @@ public class SeriesService : ISeriesService }; public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, - ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService) + ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService, + IReadingListService readingListService) { _unitOfWork = unitOfWork; _eventHub = eventHub; @@ -71,10 +71,11 @@ public class SeriesService : ISeriesService _logger = logger; _scrobblingService = scrobblingService; _localizationService = localizationService; + _readingListService = readingListService; } /// - /// Returns the first chapter for a series to extract metadata from (ie Summary, etc) + /// Returns the first chapter for a series to extract metadata from (ie Summary, etc.) /// /// The full series with all volumes and chapters on it /// @@ -117,33 +118,31 @@ public class SeriesService : ISeriesService series.Metadata ??= new SeriesMetadataBuilder() .Build(); - if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating) - { - series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata.AgeRating; - series.Metadata.AgeRatingLocked = true; - } - if (NumberHelper.IsValidYear(updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) && series.Metadata.ReleaseYear != updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) { series.Metadata.ReleaseYear = updateSeriesMetadataDto.SeriesMetadata.ReleaseYear; series.Metadata.ReleaseYearLocked = true; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.StartDate); } if (series.Metadata.PublicationStatus != updateSeriesMetadataDto.SeriesMetadata.PublicationStatus) { series.Metadata.PublicationStatus = updateSeriesMetadataDto.SeriesMetadata.PublicationStatus; series.Metadata.PublicationStatusLocked = true; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.PublicationStatus); } if (string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata.Summary)) { updateSeriesMetadataDto.SeriesMetadata.Summary = string.Empty; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Summary); } if (series.Metadata.Summary != updateSeriesMetadataDto.SeriesMetadata.Summary.Trim()) { series.Metadata.Summary = updateSeriesMetadataDto.SeriesMetadata?.Summary.Trim() ?? string.Empty; series.Metadata.SummaryLocked = true; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Summary); } if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata?.Language) @@ -169,7 +168,7 @@ public class SeriesService : ISeriesService updateSeriesMetadataDto.SeriesMetadata.Genres.Count != 0) { var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(updateSeriesMetadataDto.SeriesMetadata.Genres.Select(t => Parser.Normalize(t.Title)))).ToList(); - series.Metadata.Genres ??= new List(); + series.Metadata.Genres ??= []; GenreHelper.UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, genre => { series.Metadata.Genres.Add(genre); @@ -177,7 +176,7 @@ public class SeriesService : ISeriesService } else { - series.Metadata.Genres = new List(); + series.Metadata.Genres = []; } @@ -186,7 +185,7 @@ public class SeriesService : ISeriesService var allTags = (await _unitOfWork.TagRepository .GetAllTagsByNameAsync(updateSeriesMetadataDto.SeriesMetadata.Tags.Select(t => Parser.Normalize(t.Title)))) .ToList(); - series.Metadata.Tags ??= new List(); + series.Metadata.Tags ??= []; TagHelper.UpdateTagList(updateSeriesMetadataDto.SeriesMetadata?.Tags, series, allTags, tag => { series.Metadata.Tags.Add(tag); @@ -194,87 +193,112 @@ public class SeriesService : ISeriesService } else { - series.Metadata.Tags = new List(); + series.Metadata.Tags = []; } + if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata?.AgeRating) + { + series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata?.AgeRating ?? AgeRating.Unknown; + series.Metadata.AgeRatingLocked = true; + await _readingListService.UpdateReadingListAgeRatingForSeries(series.Id, series.Metadata.AgeRating); + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating); + } + else + { + if (!series.Metadata.AgeRatingLocked) + { + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title)); + var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings); + if (updatedRating > series.Metadata.AgeRating) + { + series.Metadata.AgeRating = updatedRating; + series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating); + } + } + } + + // Update people and locks if (updateSeriesMetadataDto.SeriesMetadata != null) { - if (PersonHelper.HasAnyPeople(updateSeriesMetadataDto.SeriesMetadata)) + series.Metadata.People ??= []; + + // Writers + if (!series.Metadata.WriterLocked || !updateSeriesMetadataDto.SeriesMetadata.WriterLocked) { - series.Metadata.People ??= new List(); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer, _unitOfWork); + } - // Writers - if (!series.Metadata.WriterLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer); - } + // Cover Artists + if (!series.Metadata.CoverArtistLocked || !updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist, _unitOfWork); + } - // Cover Artists - if (!series.Metadata.CoverArtistLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist); - } + // Colorists + if (!series.Metadata.ColoristLocked || !updateSeriesMetadataDto.SeriesMetadata.ColoristLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist, _unitOfWork); + } - // Colorists - if (!series.Metadata.ColoristLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist); - } + // Editors + if (!series.Metadata.EditorLocked || !updateSeriesMetadataDto.SeriesMetadata.EditorLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor, _unitOfWork); + } - // Editors - if (!series.Metadata.EditorLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor); - } + // Inkers + if (!series.Metadata.InkerLocked || !updateSeriesMetadataDto.SeriesMetadata.InkerLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker, _unitOfWork); + } - // Inkers - if (!series.Metadata.InkerLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker); - } + // Letterers + if (!series.Metadata.LettererLocked || !updateSeriesMetadataDto.SeriesMetadata.LettererLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer, _unitOfWork); + } - // Letterers - if (!series.Metadata.LettererLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer); - } + // Pencillers + if (!series.Metadata.PencillerLocked || !updateSeriesMetadataDto.SeriesMetadata.PencillerLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller, _unitOfWork); + } - // Pencillers - if (!series.Metadata.PencillerLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller); - } + // Publishers + if (!series.Metadata.PublisherLocked || !updateSeriesMetadataDto.SeriesMetadata.PublisherLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher, _unitOfWork); + } - // Publishers - if (!series.Metadata.PublisherLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher); - } + // Imprints + if (!series.Metadata.ImprintLocked || !updateSeriesMetadataDto.SeriesMetadata.ImprintLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint, _unitOfWork); + } - // Imprints - if (!series.Metadata.ImprintLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint); - } + // Teams + if (!series.Metadata.TeamLocked || !updateSeriesMetadataDto.SeriesMetadata.TeamLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team, _unitOfWork); + } - // Teams - if (!series.Metadata.TeamLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team); - } + // Locations + if (!series.Metadata.LocationLocked || !updateSeriesMetadataDto.SeriesMetadata.LocationLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location, _unitOfWork); + } - // Locations - if (!series.Metadata.LocationLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location); - } - - // Translators - if (!series.Metadata.TranslatorLocked) - { - await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator); - } + // Translators + if (!series.Metadata.TranslatorLocked || !updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator, _unitOfWork); + } + // Characters + if (!series.Metadata.CharacterLocked || !updateSeriesMetadataDto.SeriesMetadata.CharacterLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Characters, PersonRole.Character, _unitOfWork); } series.Metadata.AgeRatingLocked = updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked; @@ -291,6 +315,7 @@ public class SeriesService : ISeriesService series.Metadata.PencillerLocked = updateSeriesMetadataDto.SeriesMetadata.PencillerLocked; series.Metadata.PublisherLocked = updateSeriesMetadataDto.SeriesMetadata.PublisherLocked; series.Metadata.TranslatorLocked = updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked; + series.Metadata.LocationLocked = updateSeriesMetadataDto.SeriesMetadata.LocationLocked; series.Metadata.CoverArtistLocked = updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked; series.Metadata.WriterLocked = updateSeriesMetadataDto.SeriesMetadata.WriterLocked; series.Metadata.SummaryLocked = updateSeriesMetadataDto.SeriesMetadata.SummaryLocked; @@ -302,9 +327,10 @@ public class SeriesService : ISeriesService return true; } + _unitOfWork.SeriesRepository.Update(series.Metadata); await _unitOfWork.CommitAsync(); - // Trigger code to cleanup tags, collections, people, etc + // Trigger code to clean up tags, collections, people, etc try { await _taskScheduler.CleanupDbEntries(); @@ -331,16 +357,21 @@ public class SeriesService : ISeriesService /// /// /// - private async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection peopleDtos, PersonRole role) + public static async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection peopleDtos, PersonRole role, IUnitOfWork unitOfWork) { + // TODO: Cleanup this code so we aren't using UnitOfWork like this + // Normalize all names from the DTOs - var normalizedNames = peopleDtos.Select(p => Parser.Normalize(p.Name)).ToList(); + var normalizedNames = peopleDtos + .Select(p => Parser.Normalize(p.Name)) + .Distinct() + .ToList(); // Bulk select people who already exist in the database - var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); + var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); // Use a dictionary for quick lookups - var existingPeopleDictionary = existingPeople.ToDictionary(p => p.NormalizedName, p => p); + var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople); // List to track people that will be added to the metadata var peopleToAdd = new List(); @@ -350,13 +381,28 @@ public class SeriesService : ISeriesService var normalizedPersonName = Parser.Normalize(personDto.Name); // Check if the person exists in the dictionary - if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out _)) continue; + if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out var p)) + { + // TODO: Should I add more controls here to map back? + if (personDto.AniListId > 0 && p.AniListId <= 0 && p.AniListId != personDto.AniListId) + { + p.AniListId = personDto.AniListId; + } + p.Description = string.IsNullOrEmpty(p.Description) ? personDto.Description : p.Description; + continue; // If we ever want to update metadata for existing people, we'd do it here + } // Person doesn't exist, so create a new one var newPerson = new Person { Name = personDto.Name, - NormalizedName = normalizedPersonName + NormalizedName = normalizedPersonName, + AniListId = personDto.AniListId, + Description = personDto.Description, + Asin = personDto.Asin, + CoverImage = personDto.CoverImage, + MalId = personDto.MalId, + HardcoverId = personDto.HardcoverId, }; peopleToAdd.Add(newPerson); @@ -366,7 +412,7 @@ public class SeriesService : ISeriesService // Add any new people to the database in bulk if (peopleToAdd.Count != 0) { - _unitOfWork.PersonRepository.Attach(peopleToAdd); + unitOfWork.PersonRepository.Attach(peopleToAdd); } // Now that we have all the people (new and existing), update the SeriesMetadataPeople @@ -408,63 +454,12 @@ public class SeriesService : ISeriesService } - - /// - /// - /// - /// User with Ratings includes - /// - /// - public async Task UpdateRating(AppUser? user, UpdateSeriesRatingDto updateSeriesRatingDto) - { - if (user == null) - { - _logger.LogError("Cannot update rating of null user"); - return false; - } - - var userRating = - await _unitOfWork.UserRepository.GetUserRatingAsync(updateSeriesRatingDto.SeriesId, user.Id) ?? - new AppUserRating(); - try - { - userRating.Rating = Math.Clamp(updateSeriesRatingDto.UserRating, 0f, 5f); - userRating.HasBeenRated = true; - userRating.SeriesId = updateSeriesRatingDto.SeriesId; - - if (userRating.Id == 0) - { - user.Ratings ??= new List(); - user.Ratings.Add(userRating); - } - - _unitOfWork.UserRepository.Update(user); - - if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) - { - BackgroundJob.Enqueue(() => - _scrobblingService.ScrobbleRatingUpdate(user.Id, updateSeriesRatingDto.SeriesId, - userRating.Rating)); - return true; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an exception saving rating"); - } - - await _unitOfWork.RollbackAsync(); - user.Ratings?.Remove(userRating); - - return false; - } - public async Task DeleteMultipleSeries(IList seriesIds) { try { var chapterMappings = - await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(seriesIds.ToArray()); + await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync([.. seriesIds]); var allChapterIds = new List(); foreach (var mapping in chapterMappings) @@ -472,9 +467,8 @@ public class SeriesService : ISeriesService allChapterIds.AddRange(mapping.Value); } - // NOTE: This isn't getting all the people and whatnot currently + // NOTE: This isn't getting all the people and whatnot currently due to the lack of includes var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(seriesIds); - _unitOfWork.SeriesRepository.Remove(series); var libraryIds = series.Select(s => s.LibraryId); @@ -495,7 +489,8 @@ public class SeriesService : ISeriesService await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries(); - _taskScheduler.CleanupChapters(allChapterIds.ToArray()); + _taskScheduler.CleanupChapters([.. allChapterIds]); + return true; } catch (Exception ex) @@ -874,19 +869,19 @@ public class SeriesService : ISeriesService // Calculate the time differences between consecutive chapters var timeDifferences = new List(); DateTime? previousChapterTime = null; - foreach (var chapter in chapters) + foreach (var chapterCreatedUtc in chapters.Select(c => c.CreatedUtc)) { - if (previousChapterTime.HasValue && (chapter.CreatedUtc - previousChapterTime.Value) <= TimeSpan.FromHours(1)) + if (previousChapterTime.HasValue && (chapterCreatedUtc - previousChapterTime.Value) <= TimeSpan.FromHours(1)) { continue; // Skip this chapter if it's within an hour of the previous one } - if ((chapter.CreatedUtc - previousChapterTime ?? TimeSpan.Zero) != TimeSpan.Zero) + if ((chapterCreatedUtc - previousChapterTime ?? TimeSpan.Zero) != TimeSpan.Zero) { - timeDifferences.Add(chapter.CreatedUtc - previousChapterTime ?? TimeSpan.Zero); + timeDifferences.Add(chapterCreatedUtc - previousChapterTime ?? TimeSpan.Zero); } - previousChapterTime = chapter.CreatedUtc; + previousChapterTime = chapterCreatedUtc; } if (timeDifferences.Count < minimumTimeDeltas) @@ -910,9 +905,14 @@ public class SeriesService : ISeriesService } // Calculate the forecast for when the next chapter is expected - var nextChapterExpected = chapters.Any() - ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference) - : (DateTime?)null; + // var nextChapterExpected = chapters.Count > 0 + // ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference) + // : (DateTime?)null; + var lastChapterDate = chapters.Max(c => c.CreatedUtc); + var estimatedDate = lastChapterDate.AddDays(forecastedTimeDifference); + var nextChapterExpected = estimatedDate.Day > DateTime.DaysInMonth(estimatedDate.Year, estimatedDate.Month) + ? new DateTime(estimatedDate.Year, estimatedDate.Month, DateTime.DaysInMonth(estimatedDate.Year, estimatedDate.Month)) + : estimatedDate; // For number and volume number, we need the highest chapter, not the latest created var lastChapter = chapters.MaxBy(c => c.MaxNumber)!; @@ -936,6 +936,7 @@ public class SeriesService : ISeriesService { LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber), LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", "#", result.ChapterNumber), + LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", "#", result.ChapterNumber), LibraryType.Book => await _localizationService.Translate(userId, "book-num", result.ChapterNumber), LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", result.ChapterNumber), _ => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber) diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs new file mode 100644 index 000000000..fd44b5962 --- /dev/null +++ b/API/Services/SettingsService.cs @@ -0,0 +1,447 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Settings; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Logging; +using API.Services.Tasks.Scanner; +using Hangfire; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface ISettingsService +{ + Task UpdateMetadataSettings(MetadataSettingsDto dto); + Task UpdateSettings(ServerSettingDto updateSettingsDto); +} + + +public class SettingsService : ISettingsService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IDirectoryService _directoryService; + private readonly ILibraryWatcher _libraryWatcher; + private readonly ITaskScheduler _taskScheduler; + private readonly ILogger _logger; + + public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService, + ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler, + ILogger logger) + { + _unitOfWork = unitOfWork; + _directoryService = directoryService; + _libraryWatcher = libraryWatcher; + _taskScheduler = taskScheduler; + _logger = logger; + } + + /// + /// Update the metadata settings for Kavita+ Metadata feature + /// + /// + /// + public async Task UpdateMetadataSettings(MetadataSettingsDto dto) + { + var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + existingMetadataSetting.Enabled = dto.Enabled; + existingMetadataSetting.EnableSummary = dto.EnableSummary; + existingMetadataSetting.EnableLocalizedName = dto.EnableLocalizedName; + existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus; + existingMetadataSetting.EnableRelationships = dto.EnableRelationships; + existingMetadataSetting.EnablePeople = dto.EnablePeople; + existingMetadataSetting.EnableStartDate = dto.EnableStartDate; + existingMetadataSetting.EnableGenres = dto.EnableGenres; + existingMetadataSetting.EnableTags = dto.EnableTags; + existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming; + existingMetadataSetting.EnableCoverImage = dto.EnableCoverImage; + + existingMetadataSetting.EnableChapterPublisher = dto.EnableChapterPublisher; + existingMetadataSetting.EnableChapterSummary = dto.EnableChapterSummary; + existingMetadataSetting.EnableChapterTitle = dto.EnableChapterTitle; + existingMetadataSetting.EnableChapterReleaseDate = dto.EnableChapterReleaseDate; + existingMetadataSetting.EnableChapterCoverImage = dto.EnableChapterCoverImage; + + existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? []; + + existingMetadataSetting.Blacklist = (dto.Blacklist ?? []).Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? []; + existingMetadataSetting.Whitelist = (dto.Whitelist ?? []).Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? []; + existingMetadataSetting.Overrides = [.. dto.Overrides ?? []]; + existingMetadataSetting.PersonRoles = dto.PersonRoles ?? []; + + // Handle Field Mappings + + // Clear existing mappings + existingMetadataSetting.FieldMappings ??= []; + _unitOfWork.SettingsRepository.RemoveRange(existingMetadataSetting.FieldMappings); + existingMetadataSetting.FieldMappings.Clear(); + + if (dto.FieldMappings != null) + { + // Add new mappings + foreach (var mappingDto in dto.FieldMappings) + { + existingMetadataSetting.FieldMappings.Add(new MetadataFieldMapping + { + SourceType = mappingDto.SourceType, + DestinationType = mappingDto.DestinationType, + SourceValue = mappingDto.SourceValue, + DestinationValue = mappingDto.DestinationValue, + ExcludeFromSource = mappingDto.ExcludeFromSource + }); + } + } + + // Save changes + await _unitOfWork.CommitAsync(); + + // Return updated settings + return await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + } + + /// + /// Update Server Settings + /// + /// + /// + /// + public async Task UpdateSettings(ServerSettingDto updateSettingsDto) + { + // We do not allow CacheDirectory changes, so we will ignore. + var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); + var updateBookmarks = false; + var originalBookmarkDirectory = _directoryService.BookmarkDirectory; + + var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; + if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && + !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) + { + bookmarkDirectory = + _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); + } + + if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) + { + bookmarkDirectory = _directoryService.BookmarkDirectory; + } + + var updateTask = false; + foreach (var setting in currentSettings) + { + if (setting.Key == ServerSettingKey.OnDeckProgressDays && + updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.OnDeckUpdateDays && + updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) + { + if (OsInfo.IsDocker) continue; + setting.Value = updateSettingsDto.Port + string.Empty; + // Port is managed in appSetting.json + Configuration.Port = updateSettingsDto.Port; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.CacheSize && + updateSettingsDto.CacheSize + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.CacheSize + string.Empty; + // CacheSize is managed in appSetting.json + Configuration.CacheSize = updateSettingsDto.CacheSize; + _unitOfWork.SettingsRepository.Update(setting); + } + + updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto); + + UpdateEmailSettings(setting, updateSettingsDto); + + + + if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) + { + if (OsInfo.IsDocker) continue; + // Validate IP addresses + foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (!IPAddress.TryParse(ipAddress.Trim(), out _)) + { + throw new KavitaException("ip-address-invalid"); + } + } + + setting.Value = updateSettingsDto.IpAddresses; + // IpAddresses is managed in appSetting.json + Configuration.IpAddresses = updateSettingsDto.IpAddresses; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) + { + var path = !updateSettingsDto.BaseUrl.StartsWith('/') + ? $"/{updateSettingsDto.BaseUrl}" + : updateSettingsDto.BaseUrl; + path = !path.EndsWith('/') + ? $"{path}/" + : path; + setting.Value = path; + Configuration.BaseUrl = updateSettingsDto.BaseUrl; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.LoggingLevel && + updateSettingsDto.LoggingLevel + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.LoggingLevel + string.Empty; + LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EnableOpds && + updateSettingsDto.EnableOpds + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableOpds + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EncodeMediaAs && + ((int)updateSettingsDto.EncodeMediaAs).ToString() != setting.Value) + { + setting.Value = ((int)updateSettingsDto.EncodeMediaAs).ToString(); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.CoverImageSize && + ((int)updateSettingsDto.CoverImageSize).ToString() != setting.Value) + { + setting.Value = ((int)updateSettingsDto.CoverImageSize).ToString(); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value) + { + setting.Value = (updateSettingsDto.HostName + string.Empty).Trim(); + setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) + { + // Validate new directory can be used + if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) + { + throw new KavitaException("bookmark-dir-permissions"); + } + + originalBookmarkDirectory = setting.Value; + + // Normalize the path deliminators. Just to look nice in DB, no functionality + setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); + _unitOfWork.SettingsRepository.Update(setting); + updateBookmarks = true; + + } + + if (setting.Key == ServerSettingKey.AllowStatCollection && + updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.TotalBackups && + updateSettingsDto.TotalBackups + string.Empty != setting.Value) + { + if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) + { + throw new KavitaException("total-backups"); + } + + setting.Value = updateSettingsDto.TotalBackups + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.TotalLogs && + updateSettingsDto.TotalLogs + string.Empty != setting.Value) + { + if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1) + { + throw new KavitaException("total-logs"); + } + + setting.Value = updateSettingsDto.TotalLogs + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EnableFolderWatching && + updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } + + if (!_unitOfWork.HasChanges()) return updateSettingsDto; + + try + { + await _unitOfWork.CommitAsync(); + + if (!updateSettingsDto.AllowStatCollection) + { + _taskScheduler.CancelStatsTasks(); + } + else + { + await _taskScheduler.ScheduleStatsTasks(); + } + + if (updateBookmarks) + { + UpdateBookmarkDirectory(originalBookmarkDirectory, bookmarkDirectory); + } + + if (updateTask) + { + BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); + } + + if (updateSettingsDto.EnableFolderWatching) + { + BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); + } + else + { + BackgroundJob.Enqueue(() => _libraryWatcher.StopWatching()); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when updating server settings"); + await _unitOfWork.RollbackAsync(); + throw new KavitaException("generic-error"); + } + + + _logger.LogInformation("Server Settings updated"); + + return updateSettingsDto; + } + + private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) + { + _directoryService.ExistOrCreate(bookmarkDirectory); + _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); + _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); + } + + private bool UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) + { + setting.Value = updateSettingsDto.TaskBackup; + _unitOfWork.SettingsRepository.Update(setting); + + return true; + } + + if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) + { + setting.Value = updateSettingsDto.TaskScan; + _unitOfWork.SettingsRepository.Update(setting); + return true; + } + + if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value) + { + setting.Value = updateSettingsDto.TaskCleanup; + _unitOfWork.SettingsRepository.Update(setting); + return true; + } + return false; + } + + private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key == ServerSettingKey.EmailHost && + updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailPort && + updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailAuthPassword && + updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailAuthUserName && + updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSenderAddress && + updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSenderDisplayName && + updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSizeLimit && + updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailEnableSsl && + updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailCustomizedTemplates && + updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } +} diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 8fa22cc2f..006bad184 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -36,7 +36,6 @@ public interface IStatisticService IEnumerable> GetWordsReadCountByYear(int userId = 0); Task UpdateServerStatistics(); Task TimeSpentReadingForUsersAsync(IList userIds, IList libraryIds); - Task GetKavitaPlusMetadataBreakdown(); Task> GetFilesByExtension(string fileExtension); } @@ -89,7 +88,9 @@ public class StatisticService : IStatisticService var lastActive = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) - .MaxAsync(p => p.LastModified); + .Select(p => p.LastModified) + .DefaultIfEmpty() + .MaxAsync(); // First get the total pages per library @@ -127,12 +128,25 @@ public class StatisticService : IStatisticService var earliestReadDate = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) - .MinAsync(p => p.Created); + .Select(p => p.Created) + .DefaultIfEmpty() + .MinAsync(); + + if (earliestReadDate == DateTime.MinValue) + { + averageReadingTimePerWeek = 0; + } + else + { +#pragma warning disable S6561 + var timeDifference = DateTime.Now - earliestReadDate; +#pragma warning restore S6561 + var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7); + + averageReadingTimePerWeek /= deltaWeeks; + } - var timeDifference = DateTime.Now - earliestReadDate; - var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7); - averageReadingTimePerWeek /= deltaWeeks; return new UserReadStatistics() @@ -344,6 +358,7 @@ public class StatisticService : IStatisticService SeriesId = u.SeriesId, LibraryId = u.LibraryId, ReadDate = u.LastModified, + ReadDateUtc = u.LastModifiedUtc, ChapterId = u.ChapterId, ChapterNumber = _context.Chapter.Single(c => c.Id == u.ChapterId).MinNumber }) @@ -540,29 +555,6 @@ public class StatisticService : IStatisticService p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages)))); } - public async Task GetKavitaPlusMetadataBreakdown() - { - // We need to count number of Series that have an external series record - // Then count how many series are blacklisted - // Then get total count of series that are Kavita+ eligible - var plusLibraries = await _context.Library - .Where(l => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(l.Type)) - .Select(l => l.Id) - .ToListAsync(); - - var countOfBlacklisted = await _context.SeriesBlacklist.CountAsync(); - var totalSeries = await _context.Series.Where(s => plusLibraries.Contains(s.LibraryId)).CountAsync(); - var seriesWithMetadata = await _context.ExternalSeriesMetadata.CountAsync(); - - return new KavitaPlusMetadataBreakdownDto() - { - TotalSeries = totalSeries, - ErroredSeries = countOfBlacklisted, - SeriesCompleted = seriesWithMetadata - }; - - } - public async Task> GetFilesByExtension(string fileExtension) { var query = _context.MangaFile diff --git a/API/Services/StreamService.cs b/API/Services/StreamService.cs index f12f10a8a..1f2e55579 100644 --- a/API/Services/StreamService.cs +++ b/API/Services/StreamService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -11,6 +12,7 @@ using API.Helpers; using API.SignalR; using Kavita.Common; using Kavita.Common.Helpers; +using Microsoft.Extensions.Logging; namespace API.Services; @@ -33,6 +35,9 @@ public interface IStreamService Task CreateExternalSource(int userId, ExternalSourceDto dto); Task UpdateExternalSource(int userId, ExternalSourceDto dto); Task DeleteExternalSource(int userId, int externalSourceId); + Task DeleteSideNavSmartFilterStream(int userId, int sideNavStreamId); + Task DeleteDashboardSmartFilterStream(int userId, int dashboardStreamId); + Task RenameSmartFilterStreams(AppUserSmartFilter smartFilter); } public class StreamService : IStreamService @@ -40,12 +45,14 @@ public class StreamService : IStreamService private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; + private readonly ILogger _logger; - public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService) + public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService, ILogger logger) { _unitOfWork = unitOfWork; _eventHub = eventHub; _localizationService = localizationService; + _logger = logger; } public async Task> GetDashboardStreams(int userId, bool visibleOnly = true) @@ -91,6 +98,7 @@ public class StreamService : IStreamService var ret = new DashboardStreamDto() { + Id = createdStream.Id, Name = createdStream.Name, IsProvided = createdStream.IsProvided, Visible = createdStream.Visible, @@ -123,7 +131,10 @@ public class StreamService : IStreamService AppUserIncludes.DashboardStreams); var stream = user?.DashboardStreams.FirstOrDefault(d => d.Id == dto.Id); if (stream == null) + { throw new KavitaException(await _localizationService.Translate(userId, "dashboard-stream-doesnt-exist")); + } + if (stream.Order == dto.ToPosition) return; var list = user!.DashboardStreams.OrderBy(s => s.Order).ToList(); @@ -179,6 +190,7 @@ public class StreamService : IStreamService var ret = new SideNavStreamDto() { + Id = createdStream.Id, Name = createdStream.Name, IsProvided = createdStream.IsProvided, Visible = createdStream.Visible, @@ -341,4 +353,72 @@ public class StreamService : IStreamService await _unitOfWork.CommitAsync(); } + + public async Task DeleteSideNavSmartFilterStream(int userId, int sideNavStreamId) + { + try + { + var stream = await _unitOfWork.UserRepository.GetSideNavStream(sideNavStreamId); + if (stream == null) throw new KavitaException("sidenav-stream-doesnt-exist"); + + if (stream.AppUserId != userId) throw new KavitaException("sidenav-stream-doesnt-exist"); + + + if (stream.StreamType != SideNavStreamType.SmartFilter) + { + throw new KavitaException("sidenav-stream-only-delete-smart-filter"); + } + + _unitOfWork.UserRepository.Delete(stream); + + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception deleting SideNav Smart Filter Stream: {FilterId}", sideNavStreamId); + throw; + } + } + + public async Task DeleteDashboardSmartFilterStream(int userId, int dashboardStreamId) + { + try + { + var stream = await _unitOfWork.UserRepository.GetDashboardStream(dashboardStreamId); + if (stream == null) throw new KavitaException("dashboard-stream-doesnt-exist"); + + if (stream.AppUserId != userId) throw new KavitaException("dashboard-stream-doesnt-exist"); + + if (stream.StreamType != DashboardStreamType.SmartFilter) + { + throw new KavitaException("dashboard-stream-only-delete-smart-filter"); + } + + _unitOfWork.UserRepository.Delete(stream); + + await _unitOfWork.CommitAsync(); + } catch (Exception ex) + { + _logger.LogError(ex, "There was an exception deleting Dashboard Smart Filter Stream: {FilterId}", dashboardStreamId); + throw; + } + } + + public async Task RenameSmartFilterStreams(AppUserSmartFilter smartFilter) + { + var sideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreamWithFilter(smartFilter.Id); + var dashboardStreams = await _unitOfWork.UserRepository.GetDashboardStreamWithFilter(smartFilter.Id); + + foreach (var sideNavStream in sideNavStreams) + { + sideNavStream.Name = smartFilter.Name; + } + + foreach (var dashboardStream in dashboardStreams) + { + dashboardStream.Name = smartFilter.Name; + } + + await _unitOfWork.CommitAsync(); + } } diff --git a/API/Services/TachiyomiService.cs b/API/Services/TachiyomiService.cs index f65bb00c4..7cba28695 100644 --- a/API/Services/TachiyomiService.cs +++ b/API/Services/TachiyomiService.cs @@ -30,12 +30,12 @@ public class TachiyomiService : ITachiyomiService { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IReaderService _readerService; private static readonly CultureInfo EnglishCulture = CultureInfo.CreateSpecificCulture("en-US"); - public TachiyomiService(IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, IReaderService readerService) + public TachiyomiService(IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, IReaderService readerService) { _unitOfWork = unitOfWork; _readerService = readerService; diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 24083d19d..575f89b3b 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -6,12 +6,15 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.Entities.Enums; +using API.Extensions; +using API.Helpers; using API.Helpers.Converters; using API.Services.Plus; using API.Services.Tasks; using API.Services.Tasks.Metadata; using API.SignalR; using Hangfire; +using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; namespace API.Services; @@ -31,7 +34,6 @@ public interface ITaskScheduler void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false, bool forceColorscape = false); Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false); - void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false); void CancelStatsTasks(); Task RunStatCollection(); void CovertAllCoversToEncoding(); @@ -59,6 +61,7 @@ public class TaskScheduler : ITaskScheduler private readonly ILicenseService _licenseService; private readonly IExternalMetadataService _externalMetadataService; private readonly ISmartCollectionSyncService _smartCollectionSyncService; + private readonly IWantToReadSyncService _wantToReadSyncService; private readonly IEventHub _eventHub; public static BackgroundJobServer Client => new (); @@ -79,9 +82,11 @@ public class TaskScheduler : ITaskScheduler public const string LicenseCheckId = "license-check"; public const string KavitaPlusDataRefreshId = "kavita+-data-refresh"; public const string KavitaPlusStackSyncId = "kavita+-stack-sync"; + public const string KavitaPlusWantToReadSyncId = "kavita+-want-to-read-sync"; public static readonly ImmutableArray ScanTasks = ["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"]; + private static readonly ImmutableArray NonCronOptions = ["disabled", "daily", "weekly"]; private static readonly Random Rnd = new Random(); @@ -96,7 +101,8 @@ public class TaskScheduler : ITaskScheduler ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService, - IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, IEventHub eventHub) + IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, + IWantToReadSyncService wantToReadSyncService, IEventHub eventHub) { _cacheService = cacheService; _logger = logger; @@ -115,6 +121,7 @@ public class TaskScheduler : ITaskScheduler _licenseService = licenseService; _externalMetadataService = externalMetadataService; _smartCollectionSyncService = smartCollectionSyncService; + _wantToReadSyncService = wantToReadSyncService; _eventHub = eventHub; } @@ -122,22 +129,31 @@ public class TaskScheduler : ITaskScheduler { _logger.LogInformation("Scheduling reoccurring tasks"); + var setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).Value; - if (setting != null) + if (IsInvalidCronSetting(setting)) + { + _logger.LogError("Scan Task has invalid cron, defaulting to Daily"); + RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), + Cron.Daily, RecurringJobOptions); + } + else { var scanLibrarySetting = setting; _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), () => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions); } - else - { - RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), - Cron.Daily, RecurringJobOptions); - } + setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value; - if (setting != null) + if (IsInvalidCronSetting(setting)) + { + _logger.LogError("Backup Task has invalid cron, defaulting to Weekly"); + RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), + Cron.Weekly, RecurringJobOptions); + } + else { _logger.LogDebug("Scheduling Backup Task for {Setting}", setting); var schedule = CronConverter.ConvertToCronNotation(setting); @@ -149,16 +165,21 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => schedule, RecurringJobOptions); } - else - { - RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), - Cron.Weekly, RecurringJobOptions); - } setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskCleanup)).Value; - _logger.LogDebug("Scheduling Cleanup Task for {Setting}", setting); - RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), - CronConverter.ConvertToCronNotation(setting), RecurringJobOptions); + if (IsInvalidCronSetting(setting)) + { + _logger.LogError("Cleanup Task has invalid cron, defaulting to Daily"); + RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), + Cron.Daily, RecurringJobOptions); + } + else + { + _logger.LogDebug("Scheduling Cleanup Task for {Setting}", setting); + RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), + CronConverter.ConvertToCronNotation(setting), RecurringJobOptions); + } + RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions); @@ -171,6 +192,11 @@ public class TaskScheduler : ITaskScheduler await ScheduleKavitaPlusTasks(); } + private static bool IsInvalidCronSetting(string setting) + { + return setting == null || (!NonCronOptions.Contains(setting) && !CronHelper.IsValidCron(setting)); + } + public async Task ScheduleKavitaPlusTasks() { // KavitaPlus based (needs license check) @@ -183,23 +209,45 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(CheckScrobblingTokensId, () => _scrobblingService.CheckExternalAccessTokens(), Cron.Daily, RecurringJobOptions); BackgroundJob.Enqueue(() => _scrobblingService.CheckExternalAccessTokens()); // We also kick off an immediate check on startup - RecurringJob.AddOrUpdate(LicenseCheckId, () => _licenseService.HasActiveLicense(true), + + // Get the License Info (and cache it) on first load. This will internally cache the Github releases for the Version Service + await _licenseService.GetLicenseInfo(true); // Kick this off first to cache it then let it refresh every 9 hours (8 hour cache) + RecurringJob.AddOrUpdate(LicenseCheckId, () => _licenseService.GetLicenseInfo(false), LicenseService.Cron, RecurringJobOptions); - // KavitaPlus Scrobbling (every 4 hours) + // KavitaPlus Scrobbling (every hour) - randomise minutes to spread requests out for K+ RecurringJob.AddOrUpdate(ProcessScrobblingEventsId, () => _scrobblingService.ProcessUpdatesSinceLastSync(), - "0 */4 * * *", RecurringJobOptions); + Cron.Hourly(Rnd.Next(0, 60)), RecurringJobOptions); RecurringJob.AddOrUpdate(ProcessProcessedScrobblingEventsId, () => _scrobblingService.ClearProcessedEvents(), Cron.Daily, RecurringJobOptions); // Backfilling/Freshening Reviews/Rating/Recommendations RecurringJob.AddOrUpdate(KavitaPlusDataRefreshId, - () => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 4)), + () => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 5)), RecurringJobOptions); + // This shouldn't be so close to fetching data due to Rate limit concerns RecurringJob.AddOrUpdate(KavitaPlusStackSyncId, - () => _smartCollectionSyncService.Sync(), Cron.Daily(Rnd.Next(1, 4)), + () => _smartCollectionSyncService.Sync(), Cron.Daily(Rnd.Next(6, 10)), RecurringJobOptions); + + RecurringJob.AddOrUpdate(KavitaPlusWantToReadSyncId, + () => _wantToReadSyncService.Sync(), Cron.Weekly(DayOfWeekHelper.Random()), + RecurringJobOptions); + } + + /// + /// Removes any Kavita+ Recurring Jobs + /// + public static void RemoveKavitaPlusTasks() + { + RecurringJob.RemoveIfExists(CheckScrobblingTokensId); + RecurringJob.RemoveIfExists(LicenseCheckId); + RecurringJob.RemoveIfExists(ProcessScrobblingEventsId); + RecurringJob.RemoveIfExists(ProcessProcessedScrobblingEventsId); + RecurringJob.RemoveIfExists(KavitaPlusDataRefreshId); + RecurringJob.RemoveIfExists(KavitaPlusStackSyncId); + RecurringJob.RemoveIfExists(KavitaPlusWantToReadSyncId); } #region StatsTasks @@ -218,11 +266,6 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), RecurringJobOptions); } - public void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false) - { - _logger.LogInformation("Enqueuing library file analysis for: {LibraryId}", libraryId); - BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanLibrary(libraryId, forceUpdate)); - } /// /// Upon cancelling stat, we do report to the Stat service that we are no longer going to be reporting @@ -286,7 +329,7 @@ public class TaskScheduler : ITaskScheduler if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, normalizedOriginal]) || HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { - _logger.LogDebug("Skipped scheduling ScanFolder for {Folder} as a job already queued", + _logger.LogTrace("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } @@ -303,7 +346,7 @@ public class TaskScheduler : ITaskScheduler var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath); if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { - _logger.LogDebug("Skipped scheduling ScanFolder for {Folder} as a job already queued", + _logger.LogTrace("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 4faf59e6c..e39600c3f 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -8,8 +8,10 @@ using API.DTOs.Filtering; using API.Entities; using API.Entities.Enums; using API.Helpers; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Hangfire; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Tasks; @@ -33,6 +35,11 @@ public interface ICleanupService /// /// Task CleanupWantToRead(); + + Task ConsolidateProgress(); + + Task CleanupMediaErrors(); + } /// /// Cleans up after operations on reoccurring basis @@ -74,13 +81,23 @@ public class CleanupService : ICleanupService _logger.LogInformation("Starting Cleanup"); await SendProgress(0F, "Starting cleanup"); + _logger.LogInformation("Cleaning temp directory"); _directoryService.ClearDirectory(_directoryService.TempDirectory); + await SendProgress(0.1F, "Cleaning temp directory"); CleanupCacheAndTempDirectories(); + await SendProgress(0.25F, "Cleaning old database backups"); _logger.LogInformation("Cleaning old database backups"); await CleanupBackups(); + + await SendProgress(0.35F, "Consolidating Progress Events"); + await ConsolidateProgress(); + + await SendProgress(0.4F, "Consolidating Media Errors"); + await CleanupMediaErrors(); + await SendProgress(0.50F, "Cleaning deleted cover images"); _logger.LogInformation("Cleaning deleted cover images"); await DeleteSeriesCoverImages(); @@ -226,6 +243,108 @@ public class CleanupService : ICleanupService _logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now); } + /// + /// Find any progress events that have duplicate, find the highest page read event, then copy over information from that and delete others, to leave one. + /// + public async Task ConsolidateProgress() + { + _logger.LogInformation("Consolidating Progress Events"); + // AppUserProgress + var allProgress = await _unitOfWork.AppUserProgressRepository.GetAllProgress(); + + // Group by the unique identifiers that would make a progress entry unique + var duplicateGroups = allProgress + .GroupBy(p => new + { + p.AppUserId, + p.ChapterId, + }) + .Where(g => g.Count() > 1); + + foreach (var group in duplicateGroups) + { + // Find the entry with the highest pages read + var highestProgress = group + .OrderByDescending(p => p.PagesRead) + .ThenByDescending(p => p.LastModifiedUtc) + .First(); + + // Get the duplicate entries to remove (all except the highest progress) + var duplicatesToRemove = group + .Where(p => p.Id != highestProgress.Id) + .ToList(); + + // Copy over any non-null BookScrollId if the highest progress entry doesn't have one + if (string.IsNullOrEmpty(highestProgress.BookScrollId)) + { + var firstValidScrollId = duplicatesToRemove + .FirstOrDefault(p => !string.IsNullOrEmpty(p.BookScrollId)) + ?.BookScrollId; + + if (firstValidScrollId != null) + { + highestProgress.BookScrollId = firstValidScrollId; + highestProgress.MarkModified(); + } + } + + // Remove the duplicates + foreach (var duplicate in duplicatesToRemove) + { + _unitOfWork.AppUserProgressRepository.Remove(duplicate); + } + } + + // Save changes + await _unitOfWork.CommitAsync(); + } + + /// + /// Scans through Media Error and removes any entries that have been fixed and are within the DB (proper files where wordcount/pagecount > 0) + /// + public async Task CleanupMediaErrors() + { + try + { + List errorStrings = ["This archive cannot be read or not supported", "File format not supported"]; + var mediaErrors = await _unitOfWork.MediaErrorRepository.GetAllErrorsAsync(errorStrings); + _logger.LogInformation("Beginning consolidation of {Count} Media Errors", mediaErrors.Count); + + var pathToErrorMap = mediaErrors + .GroupBy(me => Parser.NormalizePath(me.FilePath)) + .ToDictionary( + group => group.Key, + group => group.ToList() // The same file can be duplicated (rare issue when network drives die out midscan) + ); + + var normalizedPaths = pathToErrorMap.Keys.ToList(); + + // Find all files that are valid + var validFiles = await _unitOfWork.DataContext.MangaFile + .Where(f => normalizedPaths.Contains(f.FilePath) && f.Pages > 0) + .Select(f => f.FilePath) + .ToListAsync(); + + var removalCount = 0; + foreach (var validFilePath in validFiles) + { + if (!pathToErrorMap.TryGetValue(validFilePath, out var mediaError)) continue; + + _unitOfWork.MediaErrorRepository.Remove(mediaError); + removalCount++; + } + + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Finished consolidation of {Count} Media Errors, Removed: {RemovalCount}", + mediaErrors.Count, removalCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception consolidating media errors"); + } + } + public async Task CleanupLogs() { _logger.LogInformation("Performing cleanup of logs directory"); diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs new file mode 100644 index 000000000..015613965 --- /dev/null +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -0,0 +1,740 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Extensions; +using API.SignalR; +using EasyCaching.Core; +using Flurl; +using Flurl.Http; +using HtmlAgilityPack; +using Kavita.Common; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NetVips; + + +namespace API.Services.Tasks.Metadata; +#nullable enable + +public interface ICoverDbService +{ + Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); + Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); + Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat); + Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url); + Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false); + Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false); + Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false); +} + + +public class CoverDbService : ICoverDbService +{ + private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; + private readonly IEasyCachingProviderFactory _cacheFactory; + private readonly IHostEnvironment _env; + private readonly IImageService _imageService; + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; + private TimeSpan _cacheTime = TimeSpan.FromDays(10); + + private const string NewHost = "https://www.kavitareader.com/CoversDB/"; + + private static readonly string[] ValidIconRelations = { + "icon", + "apple-touch-icon", + "apple-touch-icon-precomposed", + "apple-touch-icon icon-precomposed" // ComicVine has it combined + }; + + /// + /// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon) + /// + private static readonly Dictionary FaviconUrlMapper = new() + { + ["https://app.plex.tv"] = "https://plex.tv" + }; + /// + /// Cache of the publisher/favicon list + /// + private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(1); + + public CoverDbService(ILogger logger, IDirectoryService directoryService, + IEasyCachingProviderFactory cacheFactory, IHostEnvironment env, IImageService imageService, + IUnitOfWork unitOfWork, IEventHub eventHub) + { + _logger = logger; + _directoryService = directoryService; + _cacheFactory = cacheFactory; + _env = env; + _imageService = imageService; + _unitOfWork = unitOfWork; + _eventHub = eventHub; + } + + /// + /// Downloads the favicon image from a given website URL, optionally falling back to a custom method if standard methods fail. + /// + /// The full URL of the website to extract the favicon from. + /// The desired image encoding format for saving the favicon (e.g., WebP, PNG). + /// + /// A string representing the filename of the downloaded favicon image, saved to the configured favicon directory. + /// + /// + /// Thrown when favicon retrieval fails or if a previously failed domain is detected in cache. + /// + /// + /// This method first checks for a cached failure to avoid re-requesting bad links. + /// It then attempts to parse HTML for `link` tags pointing to `.png` favicons and + /// falls back to an internal fallback method if needed. Valid results are saved to disk. + /// + public async Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat) + { + // Parse the URL to get the domain (including subdomain) + var uri = new Uri(url); + var domain = uri.Host.Replace(Environment.NewLine, string.Empty); + var baseUrl = uri.Scheme + "://" + uri.Host; + + + var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon); + var res = await provider.GetAsync(baseUrl); + if (res.HasValue) + { + var sanitizedBaseUrl = baseUrl.Sanitize(); + _logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", sanitizedBaseUrl); + throw new KavitaException($"Kavita has already tried to fetch from {sanitizedBaseUrl} and failed. Skipping duplicate check"); + } + + await provider.SetAsync(baseUrl, string.Empty, _cacheTime); + if (FaviconUrlMapper.TryGetValue(baseUrl, out var value)) + { + url = value; + } + + var correctSizeLink = string.Empty; + + try + { + var htmlContent = url.GetStringAsync().Result; + var htmlDocument = new HtmlDocument(); + htmlDocument.LoadHtml(htmlContent); + + var pngLinks = htmlDocument.DocumentNode.Descendants("link") + .Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty))) + .Select(link => link.GetAttributeValue("href", string.Empty)) + .Where(href => href.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase)) + .ToList(); + + correctSizeLink = (pngLinks?.Find(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain); + } + + try + { + if (string.IsNullOrEmpty(correctSizeLink)) + { + correctSizeLink = await FallbackToKavitaReaderFavicon(baseUrl); + } + if (string.IsNullOrEmpty(correctSizeLink)) + { + throw new KavitaException($"Could not grab favicon from {baseUrl}"); + } + + var finalUrl = correctSizeLink; + + // If starts with //, it's coming usually from an offsite cdn + if (correctSizeLink.StartsWith("//")) + { + finalUrl = "https:" + correctSizeLink; + } + else if (!correctSizeLink.StartsWith(uri.Scheme)) + { + finalUrl = Url.Combine(baseUrl, correctSizeLink); + } + + _logger.LogTrace("Fetching favicon from {Url}", finalUrl); + // Download the favicon.ico file using Flurl + var faviconStream = await finalUrl + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + // Create the destination file path + using var image = Image.PngloadStream(faviconStream); + var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat); + + image.WriteToFile(Path.Combine(_directoryService.FaviconDirectory, filename)); + _logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain); + + return filename; + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading favicon for {Domain}", domain); + throw; + } + } + + public async Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat) + { + try + { + // Sanitize user input + publisherName = publisherName.Replace(Environment.NewLine, string.Empty).Replace("\r", string.Empty).Replace("\n", string.Empty); + var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Publisher); + var res = await provider.GetAsync(publisherName); + if (res.HasValue) + { + _logger.LogInformation("Kavita has already tried to fetch Publisher: {PublisherName} and failed. Skipping duplicate check", publisherName); + throw new KavitaException($"Kavita has already tried to fetch Publisher: {publisherName} and failed. Skipping duplicate check"); + } + + await provider.SetAsync(publisherName, string.Empty, _cacheTime); + var publisherLink = await FallbackToKavitaReaderPublisher(publisherName); + if (string.IsNullOrEmpty(publisherLink)) + { + throw new KavitaException($"Could not grab publisher image for {publisherName}"); + } + + // Create the destination file path + var filename = ImageService.GetPublisherFormat(publisherName, encodeFormat); + + _logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize()); + await DownloadImageFromUrl(publisherName, encodeFormat, publisherLink, _directoryService.PublisherDirectory); + + _logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName.Sanitize()); + + return filename; + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PublisherName}", publisherName.Sanitize()); + throw; + } + } + + /// + /// Attempts to download the Person image from CoverDB while matching against metadata within the Person + /// + /// + /// + /// Person image (in correct directory) or null if not found/error + public async Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat) + { + try + { + var personImageLink = await GetCoverPersonImagePath(person); + if (string.IsNullOrEmpty(personImageLink)) + { + throw new KavitaException($"Could not grab person image for {person.Name}"); + } + return await DownloadPersonImageAsync(person, encodeFormat, personImageLink); + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PersonName}", person.Name); + } + + return null; + } + + /// + /// Attempts to download the Person cover image from a Url + /// + /// + /// + /// + /// + /// + /// + public async Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url) + { + try + { + var personImageLink = await GetCoverPersonImagePath(person); + if (string.IsNullOrEmpty(personImageLink)) + { + throw new KavitaException($"Could not grab person image for {person.Name}"); + } + + + var filename = await DownloadImageFromUrl(ImageService.GetPersonFormat(person.Id), encodeFormat, personImageLink); + + _logger.LogDebug("Person image for {PersonName} downloaded and saved successfully", person.Name); + + return filename; + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PersonName}", person.Name); + } + + return null; + } + + private async Task DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url, string? targetDirectory = null) + { + // TODO: I need to unit test this to ensure it works when overwriting, etc + + // Target Directory defaults to CoverImageDirectory, but can be temp for when comparison between images is used + targetDirectory ??= _directoryService.CoverImageDirectory; + + // Create the destination file path + var filename = filenameWithoutExtension + encodeFormat.GetExtension(); + var targetFile = Path.Combine(targetDirectory, filename); + + _logger.LogTrace("Fetching person image from {Url}", url.Sanitize()); + // Download the file using Flurl + var imageStream = await url + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + using var image = Image.NewFromStream(imageStream); + try + { + image.WriteToFile(targetFile); + } + catch (Exception ex) + { + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.WEBP: + image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.AVIF: + image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + } + + return filename; + } + + private async Task GetCoverPersonImagePath(Person person) + { + var tempFile = Path.Join(_directoryService.LongTermCacheDirectory, "people.yml"); + + // Check if the file already exists and skip download in Development environment + if (File.Exists(tempFile)) + { + if (_env.IsDevelopment()) + { + _logger.LogInformation("Using existing people.yml file in Development environment"); + } + else + { + // Remove file if not in Development and file is older than 7 days + if (File.GetLastWriteTime(tempFile) < DateTime.Now.AddDays(-7)) + { + File.Delete(tempFile); + } + } + } + + // Download the file if it doesn't exist or was deleted due to age + if (!File.Exists(tempFile)) + { + var masterPeopleFile = await $"{NewHost}people/people.yml" + .DownloadFileAsync(_directoryService.LongTermCacheDirectory); + + if (!File.Exists(tempFile) || string.IsNullOrEmpty(masterPeopleFile)) + { + _logger.LogError("Could not download people.yml from Github"); + return null; + } + } + + + var coverDbRepository = new CoverDbRepository(tempFile); + + var coverAuthor = coverDbRepository.FindBestAuthorMatch(person); + if (coverAuthor == null || string.IsNullOrEmpty(coverAuthor.ImagePath)) + { + throw new KavitaException($"Could not grab person image for {person.Name}"); + } + + return $"{NewHost}{coverAuthor.ImagePath}"; + } + + private async Task FallbackToKavitaReaderFavicon(string baseUrl) + { + const string urlsFileName = "publishers.txt"; + var correctSizeLink = string.Empty; + var allOverrides = await GetCachedData(urlsFileName) ?? + await $"{NewHost}favicons/{urlsFileName}".GetStringAsync(); + + // Cache immediately + await CacheDataAsync(urlsFileName, allOverrides); + + + if (string.IsNullOrEmpty(allOverrides)) return correctSizeLink; + + var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty); + var externalFile = allOverrides + .Split("\n") + .FirstOrDefault(url => + cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) || + cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty) + )); + + if (string.IsNullOrEmpty(externalFile)) + { + throw new KavitaException($"Could not grab favicon from {baseUrl.Sanitize()}"); + } + + return $"{NewHost}favicons/{externalFile}"; + } + + private async Task FallbackToKavitaReaderPublisher(string publisherName) + { + const string publisherFileName = "publishers.txt"; + var allOverrides = await GetCachedData(publisherFileName) ?? + await $"{NewHost}publishers/{publisherFileName}".GetStringAsync(); + + // Cache immediately + await CacheDataAsync(publisherFileName, allOverrides); + + if (string.IsNullOrEmpty(allOverrides)) return string.Empty; + + var externalFile = allOverrides + .Split("\n") + .Select(publisherLine => + { + var tokens = publisherLine.Split("|"); + if (tokens.Length != 2) return null; + var aliases = tokens[0]; + // Multiple publisher aliases are separated by # + if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim()))) + { + return tokens[1]; + } + return null; + }) + .FirstOrDefault(url => !string.IsNullOrEmpty(url)); + + if (string.IsNullOrEmpty(externalFile)) + { + throw new KavitaException($"Could not grab publisher image for {publisherName}"); + } + + return $"{NewHost}publishers/{externalFile}"; + } + + private async Task CacheDataAsync(string fileName, string? content) + { + if (content == null) return; + + try + { + var filePath = _directoryService.FileSystem.Path.Join(_directoryService.LongTermCacheDirectory, fileName); + await File.WriteAllTextAsync(filePath, content); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache {FileName}", fileName); + } + } + + + private async Task GetCachedData(string cacheFile) + { + // Form the full file path: + var filePath = _directoryService.FileSystem.Path.Join(_directoryService.LongTermCacheDirectory, cacheFile); + if (!File.Exists(filePath)) return null; + + var fileInfo = new FileInfo(filePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + return await File.ReadAllTextAsync(filePath); + } + + return null; + } + + /// + /// + /// + /// + /// + /// + /// Will check against all known null image placeholders to avoid writing it + public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false) + { + if (!string.IsNullOrEmpty(url)) + { + var tempDir = _directoryService.TempDirectory; + var format = ImageService.GetPersonFormat(person.Id); + var finalFileName = format + ".webp"; + var tempFileName = format + "_new"; + var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDir); + + if (!string.IsNullOrEmpty(tempFilePath)) + { + var tempFullPath = Path.Combine(tempDir, tempFilePath); + var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName); + + // Skip setting image if it's similar to a known placeholder + if (checkNoImagePlaceholder) + { + var placeholderPath = Path.Combine(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg"); + var similarity = placeholderPath.CalculateSimilarity(tempFullPath); + if (similarity >= 0.9f) + { + _logger.LogInformation("Skipped setting placeholder image for person {PersonId} due to high similarity ({Similarity})", person.Id, similarity); + _directoryService.DeleteFiles([tempFullPath]); + return; + } + } + + try + { + if (!string.IsNullOrEmpty(person.CoverImage)) + { + var existingPath = Path.Combine(_directoryService.CoverImageDirectory, person.CoverImage); + var betterImage = existingPath.GetBetterImage(tempFullPath)!; + + var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); + if (choseNewImage) + { + _directoryService.DeleteFiles([existingPath]); + _directoryService.CopyFile(tempFullPath, finalFullPath); + person.CoverImage = finalFileName; + } + else + { + _directoryService.DeleteFiles([tempFullPath]); + return; + } + } + else + { + _directoryService.CopyFile(tempFullPath, finalFullPath); + person.CoverImage = finalFileName; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error choosing better image for Person: {PersonId}", person.Id); + _directoryService.CopyFile(tempFullPath, finalFullPath); + person.CoverImage = finalFileName; + } + + _directoryService.DeleteFiles([tempFullPath]); + + person.CoverImageLocked = true; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + } + } + else + { + person.CoverImage = string.Empty; + person.CoverImageLocked = false; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false); + } + } + + /// + /// Sets the series cover by url + /// + /// + /// + /// + /// If images are similar, will choose the higher quality image + public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false) + { + if (!string.IsNullOrEmpty(url)) + { + var tempDir = _directoryService.TempDirectory; + var format = ImageService.GetSeriesFormat(series.Id); + var finalFileName = format + ".webp"; + var tempFileName = format + "_new"; + var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDir); + + if (!string.IsNullOrEmpty(tempFilePath)) + { + var tempFullPath = Path.Combine(tempDir, tempFilePath); + var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName); + + if (chooseBetterImage && !string.IsNullOrEmpty(series.CoverImage)) + { + try + { + var existingPath = Path.Combine(_directoryService.CoverImageDirectory, series.CoverImage); + var betterImage = existingPath.GetBetterImage(tempFullPath)!; + + var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); + if (choseNewImage) + { + // Don't delete the Series cover unless it is an override, otherwise the first chapter will be null + if (existingPath.Contains(ImageService.GetSeriesFormat(series.Id))) + { + _directoryService.DeleteFiles([existingPath]); + } + + _directoryService.CopyFile(tempFullPath, finalFullPath); + series.CoverImage = finalFileName; + } + else + { + _directoryService.DeleteFiles([tempFullPath]); + return; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error choosing better image for Series: {SeriesId}", series.Id); + _directoryService.CopyFile(tempFullPath, finalFullPath); + series.CoverImage = finalFileName; + } + } + else + { + _directoryService.CopyFile(tempFullPath, finalFullPath); + series.CoverImage = finalFileName; + } + + _directoryService.DeleteFiles([tempFullPath]); + series.CoverImageLocked = true; + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + } + } + else + { + series.CoverImage = null; + series.CoverImageLocked = false; + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); + } + } + + // TODO: Refactor this to IHasCoverImage instead of a hard entity type + public async Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false) + { + if (!string.IsNullOrEmpty(url)) + { + var tempDirectory = _directoryService.TempDirectory; + var finalFileName = ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId) + ".webp"; + var tempFileName = ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId) + "_new"; + + var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDirectory); + + if (!string.IsNullOrEmpty(tempFilePath)) + { + var tempFullPath = Path.Combine(tempDirectory, tempFilePath); + var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName); + + if (chooseBetterImage && !string.IsNullOrEmpty(chapter.CoverImage)) + { + try + { + var existingPath = Path.Combine(_directoryService.CoverImageDirectory, chapter.CoverImage); + var betterImage = existingPath.GetBetterImage(tempFullPath)!; + var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); + + if (choseNewImage) + { + // This will fail if Cover gen is done just before this as there is a bug with files getting locked. + _directoryService.DeleteFiles([existingPath]); + _directoryService.CopyFile(tempFullPath, finalFullPath); + _directoryService.DeleteFiles([tempFullPath]); + } + else + { + _directoryService.DeleteFiles([tempFullPath]); + return; + } + + chapter.CoverImage = finalFileName; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue trying to choose a better cover image for Chapter: {FileName} ({ChapterId})", chapter.Range, chapter.Id); + } + } + else + { + // No comparison needed, just copy and rename to final + _directoryService.CopyFile(tempFullPath, finalFullPath); + _directoryService.DeleteFiles([tempFullPath]); + chapter.CoverImage = finalFileName; + } + + chapter.CoverImageLocked = true; + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); + } + } + else + { + chapter.CoverImage = null; + chapter.CoverImageLocked = false; + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync( + MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), + false + ); + } + } + + /// + /// + /// + /// + /// Filename without extension + /// + /// Not useable with fromBase64. Allows a different directory to be written to + /// + private async Task CreateThumbnail(string url, string filenameWithoutExtension, bool fromBase64 = true, string? targetDirectory = null) + { + targetDirectory ??= _directoryService.CoverImageDirectory; + + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var encodeFormat = settings.EncodeMediaAs; + var coverImageSize = settings.CoverImageSize; + + if (fromBase64) + { + return _imageService.CreateThumbnailFromBase64(url, + filenameWithoutExtension, encodeFormat, coverImageSize.GetDimensions().Width); + } + + return await DownloadImageFromUrl(filenameWithoutExtension, encodeFormat, url, targetDirectory); + } +} diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index 89c43f827..bff7001bd 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -179,7 +179,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService var pageCounter = 1; try { - using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(filePath, BookService.LenientBookReaderOptions); var totalPages = book.Content.Html.Local; foreach (var bookPage in totalPages) diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index d2e6437a3..fec0304a8 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -310,7 +310,7 @@ public class LibraryWatcher : ILibraryWatcher if (rootFolder.Count == 0) return string.Empty; // Select the first folder and join with library folder, this should give us the folder to scan. - return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1])); + return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[^1])); } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index dccbd8e90..83558eaa0 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -32,6 +32,10 @@ public class ParsedSeries /// Format of the Series /// public required MangaFormat Format { get; init; } + /// + /// Has this Series changed or not aka do we need to process it or not. + /// + public bool HasChanged { get; set; } } public class ScanResult @@ -126,6 +130,13 @@ public class ParseScannedFiles IDictionary> seriesPaths, Library library, bool forceCheck = false) { var fileExtensions = string.Join("|", library.LibraryFileTypes.Select(l => l.FileTypeGroup.GetRegex())); + + // If there are no library file types, skip scanning entirely + if (string.IsNullOrWhiteSpace(fileExtensions)) + { + return ArraySegment.Empty; + } + var matcher = BuildMatcher(library); var result = new List(); @@ -155,8 +166,10 @@ public class ParseScannedFiles // Don't process any folders where we've already scanned everything below if (processedDirs.Any(d => d.StartsWith(directory + Path.AltDirectorySeparatorChar) || d.Equals(directory))) { + var hasChanged = !HasSeriesFolderNotChangedSinceLastScan(library, seriesPaths, directory, forceCheck); // Skip this directory as we've already processed a parent unless there are loose files at that directory - CheckSurfaceFiles(result, directory, folderPath, fileExtensions, matcher); + // and they have changes + CheckSurfaceFiles(result, directory, folderPath, fileExtensions, matcher, hasChanged); continue; } @@ -171,7 +184,7 @@ public class ParseScannedFiles await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(directory, library.Name, ProgressEventType.Updated)); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, directory, forceCheck)) + if (HasSeriesFolderNotChangedSinceLastScan(library, seriesPaths, directory, forceCheck)) { HandleUnchangedFolder(result, folderPath, directory); } @@ -189,12 +202,16 @@ public class ParseScannedFiles /// /// Checks against all folder paths on file if the last scanned is >= the directory's last write time, down to the second /// + /// /// /// This should be normalized /// /// - private bool HasSeriesFolderNotChangedSinceLastScan(IDictionary> seriesPaths, string directory, bool forceCheck) + private bool HasSeriesFolderNotChangedSinceLastScan(Library library, IDictionary> seriesPaths, string directory, bool forceCheck) { + // Reverting code from: https://github.com/Kareadita/Kavita/pull/3619/files#diff-0625df477047ab9d8e97a900201f2f29b2dc0599ba58eb75cfbbd073a9f3c72f + // This is to be able to release hotfix and tackle this in appropriate time + // With the bottom-up approach, this can report a false positive where a nested folder will get scanned even though a parent is the series // This can't really be avoided. This is more likely to happen on Image chapter folder library layouts. if (forceCheck || !seriesPaths.TryGetValue(directory, out var seriesList)) @@ -202,6 +219,18 @@ public class ParseScannedFiles return false; } + // if (forceCheck) + // { + // return false; + // } + + // TryGetSeriesList falls back to parent folders to match to seriesList + // var seriesList = TryGetSeriesList(library, seriesPaths, directory); + // if (seriesList == null) + // { + // return false; + // } + foreach (var series in seriesList) { var lastWriteTime = _directoryService.GetLastWriteTime(series.LowestFolderPath!).Truncate(TimeSpan.TicksPerSecond); @@ -215,6 +244,31 @@ public class ParseScannedFiles return true; } + private IList? TryGetSeriesList(Library library, IDictionary> seriesPaths, string directory) + { + if (seriesPaths.Count == 0) + { + return null; + } + + if (string.IsNullOrEmpty(directory)) + { + return null; + } + + if (library.Folders.Any(fp => fp.Path.Equals(directory))) + { + return null; + } + + if (seriesPaths.TryGetValue(directory, out var seriesList)) + { + return seriesList; + } + + return TryGetSeriesList(library, seriesPaths, _directoryService.GetParentDirectoryName(directory)); + } + /// /// Handles directories that haven't changed since the last scan. /// @@ -248,13 +302,15 @@ public class ParseScannedFiles /// /// Performs a full scan of the directory and adds it to the result. /// - private void CheckSurfaceFiles(List result, string directory, string folderPath, string fileExtensions, GlobMatcher matcher) + private void CheckSurfaceFiles(List result, string directory, string folderPath, string fileExtensions, GlobMatcher matcher, bool hasChanged) { var files = _directoryService.ScanFiles(directory, fileExtensions, matcher, SearchOption.TopDirectoryOnly); if (files.Count == 0) { return; } + // Revert of https://github.com/Kareadita/Kavita/pull/3629/files#diff-0625df477047ab9d8e97a900201f2f29b2dc0599ba58eb75cfbbd073a9f3c72f + // for Hotfix v0.8.5.x result.Add(CreateScanResult(directory, folderPath, true, files)); } @@ -273,7 +329,7 @@ public class ParseScannedFiles await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(normalizedPath, library.Name, ProgressEventType.Updated)); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) + if (HasSeriesFolderNotChangedSinceLastScan(library, seriesPaths, normalizedPath, forceCheck)) { result.Add(CreateScanResult(folderPath, libraryRoot, false, ArraySegment.Empty)); } @@ -459,7 +515,7 @@ public class ParseScannedFiles await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started)); - _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1.A: Process {FolderCount} folders", library.Name, folders.Count()); + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1.A: Process {FolderCount} folders", library.Name, folders.Count); var processedScannedSeries = new ConcurrentBag(); foreach (var folder in folders) @@ -613,7 +669,7 @@ public class ParseScannedFiles } // Remove or clear any scan results that now have no ParserInfos after merging - return scanResults.Where(sr => sr.ParserInfos.Any()).ToList(); + return scanResults.Where(sr => sr.ParserInfos.Count > 0).ToList(); } private static List GetRelevantInfos(List allInfos) @@ -665,10 +721,17 @@ public class ParseScannedFiles } } - private void RemapSeries(IList scanResults, List allInfos, string localizedSeries, string nonLocalizedSeries) + private static void RemapSeries(IList scanResults, List allInfos, string localizedSeries, string nonLocalizedSeries) { + // If the series names are identical, no remapping is needed (rare but valid) + if (localizedSeries.ToNormalized().Equals(nonLocalizedSeries.ToNormalized())) + { + return; + } + // Find all infos that need to be remapped from the localized series to the non-localized series - var seriesToBeRemapped = allInfos.Where(i => i.Series.Equals(localizedSeries)).ToList(); + var normalizedLocalizedSeries = localizedSeries.ToNormalized(); + var seriesToBeRemapped = allInfos.Where(i => i.Series.ToNormalized().Equals(normalizedLocalizedSeries)).ToList(); foreach (var infoNeedingMapping in seriesToBeRemapped) { @@ -711,6 +774,11 @@ public class ParseScannedFiles .Select(fp => new ParserInfo { Series = fp.SeriesName, Format = fp.Format }) .ToList(); + // // We are certain TryGetSeriesList will return a valid result here, if the series wasn't present yet. It will have been changed. + // result.ParserInfos = TryGetSeriesList(library, seriesPaths, normalizedFolder)! + // .Select(fp => new ParserInfo { Series = fp.SeriesName, Format = fp.Format }) + // .ToList(); + _logger.LogDebug("[ScannerService] Skipped File Scan for {Folder} as it hasn't changed", normalizedFolder); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent($"Skipped {normalizedFolder}", library.Name, ProgressEventType.Updated)); @@ -736,7 +804,7 @@ public class ParseScannedFiles { // Process files sequentially result.ParserInfos = files - .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type)) + .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type, library.EnableMetadata)) .Where(info => info != null) .ToList()!; } @@ -744,7 +812,7 @@ public class ParseScannedFiles { // Process files in parallel var tasks = files.Select(file => Task.Run(() => - _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type))); + _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type, library.EnableMetadata))); var infos = await Task.WhenAll(tasks); result.ParserInfos = infos.Where(info => info != null).ToList()!; @@ -803,18 +871,21 @@ public class ParseScannedFiles var prevIssue = string.Empty; foreach (var chapter in chapters) { - if (float.TryParse(chapter.Chapters, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedChapter)) + // Use MinNumber in case there is a range, as otherwise sort order will cause it to be processed last + var chapterNum = + $"{Parser.Parser.MinNumberFromRange(chapter.Chapters).ToString(CultureInfo.InvariantCulture)}"; + if (float.TryParse(chapterNum, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedChapter)) { // Parsed successfully, use the numeric value counter = parsedChapter; chapter.IssueOrder = counter; // Increment for next chapter (unless the next has a similar value, then add 0.1) - if (!string.IsNullOrEmpty(prevIssue) && float.TryParse(prevIssue, CultureInfo.InvariantCulture, out var prevIssueFloat) && parsedChapter.Is(prevIssueFloat)) + if (!string.IsNullOrEmpty(prevIssue) && float.TryParse(prevIssue, NumberStyles.Any, CultureInfo.InvariantCulture, out var prevIssueFloat) && parsedChapter.Is(prevIssueFloat)) { counter += 0.1f; // bump if same value as the previous issue } - prevIssue = $"{parsedChapter}"; + prevIssue = $"{parsedChapter.ToString(CultureInfo.InvariantCulture)}"; } else { diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 7687b3040..168ca7f01 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -12,7 +12,7 @@ namespace API.Services.Tasks.Scanner.Parser; /// public class BasicParser(IDirectoryService directoryService, IDefaultParser imageParser) : DefaultParser(directoryService) { - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); // TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this. @@ -20,7 +20,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag if (Parser.IsImage(filePath)) { - return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, comicInfo); + return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, enableMetadata, comicInfo); } var ret = new ParserInfo() @@ -29,24 +29,12 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag Format = Parser.ParseFormat(filePath), Title = Parser.RemoveExtensionIfSupported(fileName)!, FullFilePath = Parser.NormalizePath(filePath), - Series = string.Empty, - ComicInfo = comicInfo + Series = Parser.ParseSeries(fileName, type), + ComicInfo = comicInfo, + Chapters = Parser.ParseChapter(fileName, type), + Volumes = Parser.ParseVolume(fileName, type), }; - // This will be called if the epub is already parsed once then we call and merge the information, if the - if (Parser.IsEpub(filePath)) - { - ret.Chapters = Parser.ParseChapter(fileName, type); - ret.Series = Parser.ParseSeries(fileName, type); - ret.Volumes = Parser.ParseVolume(fileName, type); - } - else - { - ret.Chapters = Parser.ParseChapter(fileName, type); - ret.Series = type == LibraryType.Comic ? Parser.ParseComicSeries(fileName) : Parser.ParseSeries(fileName, type); - ret.Volumes = type == LibraryType.Comic ? Parser.ParseComicVolume(fileName) : Parser.ParseVolume(fileName, type); - } - if (ret.Series == string.Empty || Parser.IsImage(filePath)) { // Try to parse information out of each folder all the way to rootPath @@ -98,7 +86,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag { ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); } - + ret.Title = Parser.CleanSpecialTitle(fileName); } if (string.IsNullOrEmpty(ret.Series)) @@ -113,7 +101,12 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag } // Patch in other information from ComicInfo - UpdateFromComicInfo(ret); + if (enableMetadata) + { + UpdateFromComicInfo(ret); + } + + if (ret.Volumes == Parser.LooseLeafVolume && ret.Chapters == Parser.DefaultChapter) { diff --git a/API/Services/Tasks/Scanner/Parser/BookParser.cs b/API/Services/Tasks/Scanner/Parser/BookParser.cs index 499e554ef..14f42c989 100644 --- a/API/Services/Tasks/Scanner/Parser/BookParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BookParser.cs @@ -5,7 +5,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class BookParser(IDirectoryService directoryService, IBookService bookService, BasicParser basicParser) : DefaultParser(directoryService) { - public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null) + public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null) { var info = bookService.ParseInfo(filePath); if (info == null) return null; @@ -35,7 +35,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer } else { - var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo); + var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, enableMetadata, comicInfo); info.Merge(info2); if (hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series, type) .Equals(Parser.LooseLeafVolume)) diff --git a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs index 4c1e6a9ae..b60f28aee 100644 --- a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs @@ -19,7 +19,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser /// /// /// - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { if (type != LibraryType.ComicVine) return null; @@ -64,7 +64,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser // When there was at least one directory and we failed to parse the series, this is the final fallback if (string.IsNullOrEmpty(info.Series)) { - info.Series = Parser.CleanTitle(directories[0], true, true); + info.Series = Parser.CleanTitle(directories[0], true); } } else @@ -81,11 +81,14 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser info.IsSpecial = Parser.IsSpecial(info.Filename, type) || Parser.IsSpecial(info.ComicInfo?.Format, type); // Patch in other information from ComicInfo - UpdateFromComicInfo(info); + if (enableMetadata) + { + UpdateFromComicInfo(info); + } if (string.IsNullOrEmpty(info.Series)) { - info.Series = Parser.CleanTitle(directoryName, true, false); + info.Series = Parser.CleanTitle(directoryName, true); } diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index f59a3b66f..687617fd7 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -8,7 +8,7 @@ namespace API.Services.Tasks.Scanner.Parser; public interface IDefaultParser { - ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null); + ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null); void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret); bool IsApplicable(string filePath, LibraryType type); } @@ -26,8 +26,9 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau /// /// Root folder /// Allows different Regex to be used for parsing. + /// Allows overriding data from metadata (ComicInfo/pdf/epub) /// or null if Series was empty - public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null); + public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null); /// /// Fills out by trying to parse volume, chapters, and series from folders @@ -130,9 +131,9 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau } // Patch is SeriesSort from ComicInfo - if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort)) + if (!string.IsNullOrEmpty(info.ComicInfo.SeriesSort)) { - info.SeriesSort = info.ComicInfo.TitleSort.Trim(); + info.SeriesSort = info.ComicInfo.SeriesSort.Trim(); } } diff --git a/API/Services/Tasks/Scanner/Parser/ImageParser.cs b/API/Services/Tasks/Scanner/Parser/ImageParser.cs index 4834a6ed5..12f9f4d50 100644 --- a/API/Services/Tasks/Scanner/Parser/ImageParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ImageParser.cs @@ -7,9 +7,9 @@ namespace API.Services.Tasks.Scanner.Parser; public class ImageParser(IDirectoryService directoryService) : DefaultParser(directoryService) { - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { - if (type != LibraryType.Image || !Parser.IsImage(filePath)) return null; + if (!IsApplicable(filePath, type)) return null; var directoryName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name; var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); @@ -29,15 +29,16 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir if (IsEmptyOrDefault(ret.Volumes, ret.Chapters)) { ret.IsSpecial = true; - ret.Volumes = $"{Parser.SpecialVolumeNumber}"; + ret.Volumes = Parser.SpecialVolume; } // Override the series name, as fallback folders needs it to try and parse folder name if (string.IsNullOrEmpty(ret.Series) || ret.Series.Equals(directoryName)) { - ret.Series = Parser.CleanTitle(directoryName, replaceSpecials: false); + ret.Series = Parser.CleanTitle(directoryName); } + return string.IsNullOrEmpty(ret.Series) ? null : ret; } diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index ebeaaa6f1..c0b130f91 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Immutable; -using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -10,7 +9,7 @@ using API.Extensions; namespace API.Services.Tasks.Scanner.Parser; #nullable enable -public static class Parser +public static partial class Parser { // NOTE: If you change this, don't forget to change in the UI (see Series Detail) public const string DefaultChapter = "-100000"; // -2147483648 @@ -25,7 +24,7 @@ public static class Parser public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500); - public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif|\.avif)"; // Don't forget to update CoverChooser + public const string ImageFileExtensions = @"(\.png|\.jpeg|\.jpg|\.webp|\.gif|\.avif)"; // Don't forget to update CoverChooser public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt"; public const string EpubFileExtension = @"\.epub"; public const string PdfFileExtension = @"\.pdf"; @@ -44,87 +43,83 @@ public static class Parser "One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel", "GN", "FCBD", "Giant Size"); - private static readonly char[] LeadingZeroesTrimChars = new[] { '0' }; + private static readonly char[] LeadingZeroesTrimChars = ['0']; - private static readonly char[] SpacesAndSeparators = { '\0', '\t', '\r', ' ', '-', ','}; + private static readonly char[] SpacesAndSeparators = ['\0', '\t', '\r', ' ', '-', ',']; private const string Number = @"\d+(\.\d)?"; private const string NumberRange = Number + @"(-" + Number + @")?"; /// - /// non greedy matching of a string where parenthesis are balanced + /// non-greedy matching of a string where parenthesis are balanced /// public const string BalancedParen = @"(?:[^()]|(?\()|(?<-open>\)))*?(?(open)(?!))"; /// - /// non greedy matching of a string where square brackets are balanced + /// non-greedy matching of a string where square brackets are balanced /// public const string BalancedBracket = @"(?:[^\[\]]|(?\[)|(?<-open>\]))*?(?(open)(?!))"; /// /// Matches [Complete], release tags like [kmts] but not [ Complete ] or [kmts ] /// private const string TagsInBrackets = $@"\[(?!\s){BalancedBracket}(? - /// Common regex patterns present in both Comics and Mangas - /// - private const string CommonSpecial = @"Specials?|One[- ]?Shot|Extra(?:\sChapter)?(?=\s)|Art Collection|Side Stories|Bonus"; /// /// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data /// /// See here for some examples https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face - public static readonly Regex FontSrcUrlRegex = new Regex(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" - + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", + public static readonly Regex FontSrcUrlRegex = new(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" + + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", MatchOptions, RegexTimeout); /// /// https://developer.mozilla.org/en-US/docs/Web/CSS/@import /// - public static readonly Regex CssImportUrlRegex = new Regex("(@import\\s([\"|']|url\\([\"|']))(?[^'\"]+)([\"|']\\)?);", + public static readonly Regex CssImportUrlRegex = new("(@import\\s([\"|']|url\\([\"|']))(?[^'\"]+)([\"|']\\)?);", MatchOptions | RegexOptions.Multiline, RegexTimeout); /// /// Misc css image references, like background-image: url(), border-image, or list-style-image /// /// Original prepend: (background|border|list-style)-image:\s?)? - public static readonly Regex CssImageUrlRegex = new Regex(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", + public static readonly Regex CssImageUrlRegex = new(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", MatchOptions, RegexTimeout); - private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, + private static readonly Regex ImageRegex = new(ImageFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions, + private static readonly Regex ArchiveFileRegex = new(ArchiveFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex ComicInfoArchiveRegex = new Regex(@"\.cbz|\.cbr|\.cb7|\.cbt", + private static readonly Regex ComicInfoArchiveRegex = new(@"\.cbz|\.cbr|\.cb7|\.cbt", MatchOptions, RegexTimeout); - private static readonly Regex XmlRegex = new Regex(XmlRegexExtensions, + private static readonly Regex XmlRegex = new(XmlRegexExtensions, MatchOptions, RegexTimeout); - private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, + private static readonly Regex BookFileRegex = new(BookFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex CoverImageRegex = new Regex(@"(? /// Normalize everything within Kavita. Some characters don't fall under Unicode, like full-width characters and need to be /// added on a case-by-case basis. /// - private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!*!+]", + private static readonly Regex NormalizeRegex = new(@"[^\p{L}0-9\+!*!+]", MatchOptions, RegexTimeout); /// /// Supports Batman (2020) or Batman (2) /// - private static readonly Regex SeriesAndYearRegex = new Regex(@"^\D+\s\((?\d+)\)$", + private static readonly Regex SeriesAndYearRegex = new(@"^\D+\s\((?\d+)\)$", MatchOptions, RegexTimeout); /// /// Recognizes the Special token only /// - private static readonly Regex SpecialTokenRegex = new Regex(@"SP\d+", + private static readonly Regex SpecialTokenRegex = new(@"SP\d+", MatchOptions, RegexTimeout); - private static readonly Regex[] MangaVolumeRegex = new[] - { + private static readonly Regex[] MangaVolumeRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -170,9 +165,9 @@ public static class Parser new Regex( @"(卷|册)(?\d+)", MatchOptions, RegexTimeout), - // Korean Volume: 제n화|권|회|장 -> Volume n, n화|권|회|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) + // Korean Volume: 제n화|회|장 -> Volume n, n화|권|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) new Regex( - @"제?(?\d+(\.\d)?)(권|회|화|장)", + @"제?(?\d+(\.\d+)?)(권|화|장)", MatchOptions, RegexTimeout), // Korean Season: 시즌n -> Season n, new Regex( @@ -197,11 +192,11 @@ public static class Parser // Russian Volume: n Том -> Volume n new Regex( @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] MangaSeriesRegex = new[] - { + private static readonly Regex[] MangaSeriesRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -271,7 +266,7 @@ public static class Parser RegexTimeout), //Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans] new Regex( - @"(?.*)(\bc\d+\b)", + @"(?.*?)(? Volume n new Regex( @"(?.+?)第(?\d+(?:(\-)\d+)?)巻", - MatchOptions, RegexTimeout), + MatchOptions, RegexTimeout) - }; + ]; - private static readonly Regex[] ComicSeriesRegex = new[] - { + private static readonly Regex[] ComicSeriesRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -467,11 +462,11 @@ public static class Parser // MUST BE LAST: Batman & Daredevil - King of New York new Regex( @"^(?.*)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] ComicVolumeRegex = new[] - { + private static readonly Regex[] ComicVolumeRegex = + [ // Thai Volume: เล่ม n -> Volume n new Regex( @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -507,11 +502,11 @@ public static class Parser // Russian Volume: n Том -> Volume n new Regex( @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] ComicChapterRegex = new[] - { + private static readonly Regex[] ComicChapterRegex = + [ // Thai Volume: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n new Regex( @"(บทที่|ตอนที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", @@ -576,11 +571,11 @@ public static class Parser // spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader) new Regex( @"^(?.+?)-(chapter-)?(?\d+)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] MangaChapterRegex = new[] - { + private static readonly Regex[] MangaChapterRegex = + [ // Thai Chapter: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n, เล่ม n -> Volume n, เล่มที่ n -> Volume n new Regex( @"(?((เล่ม|เล่มที่))?(\s|_)?\.?\d+)(\s|_)(บทที่|ตอนที่)\.?(\s|_)?(?\d+)", @@ -645,8 +640,8 @@ public static class Parser // Russian Chapter: n Главa -> Chapter n new Regex( @"(?!Том)(?\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; private static readonly Regex MangaEditionRegex = new Regex( // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz @@ -661,25 +656,6 @@ public static class Parser MatchOptions, RegexTimeout ); - private static readonly Regex MangaSpecialRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - $@"\b(?:{CommonSpecial}|Omake)\b", - MatchOptions, RegexTimeout - ); - - private static readonly Regex ComicSpecialRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - $@"\b(?:{CommonSpecial}|\d.+?(\W|-|^)Annual|Annual(\W|-|$|\s#)|Book \d.+?|Compendium(\W|-|$|\s.+?)|Omnibus(\W|-|$|\s.+?)|FCBD \d.+?|Absolute(\W|-|$|\s.+?)|Preview(\W|-|$|\s.+?)|Hors[ -]S[ée]rie|TPB|HS|THS)\b", - MatchOptions, RegexTimeout - ); - - private static readonly Regex EuropeanComicRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - @"\b(?:Bd[-\s]Fr)\b", - MatchOptions, RegexTimeout - ); - - // If SP\d+ is in the filename, we force treat it as a special regardless if volume or chapter might have been found. private static readonly Regex SpecialMarkerRegex = new Regex( @"SP\d+", @@ -732,21 +708,7 @@ public static class Parser return HasSpecialMarker(filePath); } - private static bool IsMangaSpecial(string? filePath) - { - if (string.IsNullOrEmpty(filePath)) return false; - return HasSpecialMarker(filePath); - } - - private static bool IsComicSpecial(string? filePath) - { - if (string.IsNullOrEmpty(filePath)) return false; - return HasSpecialMarker(filePath); - } - - - - public static string ParseMangaSeries(string filename) + private static string ParseMangaSeries(string filename) { foreach (var regex in MangaSeriesRegex) { @@ -754,6 +716,7 @@ public static class Parser var group = matches .Select(match => match.Groups["Series"]) .FirstOrDefault(group => group.Success && group != Match.Empty); + if (group != null) { return CleanTitle(group.Value); @@ -932,22 +895,6 @@ public static class Parser return title; } - private static string RemoveMangaSpecialTags(string title) - { - return MangaSpecialRegex.Replace(title, string.Empty); - } - - private static string RemoveEuropeanTags(string title) - { - return EuropeanComicRegex.Replace(title, string.Empty); - } - - private static string RemoveComicSpecialTags(string title) - { - return ComicSpecialRegex.Replace(title, string.Empty); - } - - /// /// Translates _ -> spaces, trims front and back of string, removes release groups @@ -959,27 +906,13 @@ public static class Parser /// /// - public static string CleanTitle(string title, bool isComic = false, bool replaceSpecials = true) + public static string CleanTitle(string title, bool isComic = false) { title = ReplaceUnderscores(title); title = RemoveEditionTagHolders(title); - if (replaceSpecials) - { - if (isComic) - { - title = RemoveComicSpecialTags(title); - title = RemoveEuropeanTags(title); - } - else - { - title = RemoveMangaSpecialTags(title); - } - } - - title = title.Trim(SpacesAndSeparators); title = EmptySpaceRegex.Replace(title, " "); @@ -1054,7 +987,7 @@ public static class Parser } // Check if there is a range or not - if (Regex.IsMatch(range, @"\d-{1}\d")) + if (NumberRangeRegex().IsMatch(range)) { var tokens = range.Replace("_", string.Empty).Split("-", StringSplitOptions.RemoveEmptyEntries); @@ -1081,7 +1014,7 @@ public static class Parser } // Check if there is a range or not - if (Regex.IsMatch(range, @"\d-{1}\d")) + if (NumberRangeRegex().IsMatch(range)) { var tokens = range.Replace("_", string.Empty).Split("-", StringSplitOptions.RemoveEmptyEntries); @@ -1110,11 +1043,6 @@ public static class Parser { if (string.IsNullOrEmpty(name)) return name; var cleaned = SpecialTokenRegex.Replace(name.Replace('_', ' '), string.Empty).Trim(); - var lastIndex = cleaned.LastIndexOf('.'); - if (lastIndex > 0) - { - cleaned = cleaned.Substring(0, cleaned.LastIndexOf('.')).Trim(); - } return string.IsNullOrEmpty(cleaned) ? name : cleaned; } @@ -1132,7 +1060,7 @@ public static class Parser } /// - /// Validates that a Path doesn't start with certain blacklisted folders, like __MACOSX, @Recently-Snapshot, etc and that if a full path, the filename + /// Validates that a Path doesn't start with certain blacklisted folders, like __MACOSX, @Recently-Snapshot, etc. and that if a full path, the filename /// doesn't start with ._, which is a metadata file on MACOSX. /// /// @@ -1231,6 +1159,12 @@ public static class Parser return !string.IsNullOrEmpty(name) && SeriesAndYearRegex.IsMatch(name); } + /// + /// Parse a Year from a Comic Series: Series Name (YEAR) + /// + /// Harley Quinn (2024) returns 2024 + /// + /// public static string ParseYear(string? name) { if (string.IsNullOrEmpty(name)) return string.Empty; @@ -1244,10 +1178,15 @@ public static class Parser { if (string.IsNullOrEmpty(filename)) return filename; - if (Regex.IsMatch(filename, SupportedExtensions)) + if (SupportedExtensionsRegex().IsMatch(filename)) { - return Regex.Replace(filename, SupportedExtensions, string.Empty); + return SupportedExtensionsRegex().Replace(filename, string.Empty); } return filename; } + + [GeneratedRegex(SupportedExtensions)] + private static partial Regex SupportedExtensionsRegex(); + [GeneratedRegex(@"\d-{1}\d")] + private static partial Regex NumberRangeRegex(); } diff --git a/API/Services/Tasks/Scanner/Parser/PdfParser.cs b/API/Services/Tasks/Scanner/Parser/PdfParser.cs index 696a61867..80bfa9a48 100644 --- a/API/Services/Tasks/Scanner/Parser/PdfParser.cs +++ b/API/Services/Tasks/Scanner/Parser/PdfParser.cs @@ -6,7 +6,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class PdfParser(IDirectoryService directoryService) : DefaultParser(directoryService) { - public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null) + public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null) { var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); var ret = new ParserInfo @@ -68,6 +68,18 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); } + if (enableMetadata) + { + // Patch in other information from ComicInfo + UpdateFromComicInfo(ret); + + if (comicInfo != null && !string.IsNullOrEmpty(comicInfo.Title)) + { + ret.Title = comicInfo.Title.Trim(); + } + } + + if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book) { ret.IsSpecial = true; @@ -76,6 +88,19 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc ParseFromFallbackFolders(filePath, rootPath, type, ref ret); } + if (type == LibraryType.Book && comicInfo != null) + { + // For books, fall back to the Title for Series. + if (!string.IsNullOrEmpty(comicInfo.Series)) + { + ret.Series = comicInfo.Series.Trim(); + } + else if (!string.IsNullOrEmpty(comicInfo.Title)) + { + ret.Series = comicInfo.Title.Trim(); + } + } + if (string.IsNullOrEmpty(ret.Series)) { ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic); diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index b5b0ffbcf..307408adb 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -11,6 +11,7 @@ using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; using API.Helpers; using API.Helpers.Builders; @@ -110,7 +111,7 @@ public class ProcessSeries : IProcessSeries try { - _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); + _logger.LogInformation("[ScannerService] Processing series {SeriesName} with {Count} files", series.OriginalName, parsedInfos.Count); // parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort) var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo); @@ -125,13 +126,17 @@ public class ProcessSeries : IProcessSeries series.Format = firstParsedInfo.Format; } + var removePrefix = library.RemovePrefixForSortName; + var sortName = removePrefix ? BookSortTitlePrefixHelper.GetSortTitle(series.Name) : series.Name; + if (string.IsNullOrEmpty(series.SortName)) { - series.SortName = series.Name; + series.SortName = sortName; } + if (!series.SortNameLocked) { - series.SortName = series.Name; + series.SortName = sortName; if (!string.IsNullOrEmpty(firstParsedInfo.SeriesSort)) { series.SortName = firstParsedInfo.SeriesSort; @@ -193,8 +198,8 @@ public class ProcessSeries : IProcessSeries if (seriesAdded) { // See if any recommendations can link up to the series and pre-fetch external metadata for the series - BackgroundJob.Enqueue(() => - _externalMetadataService.GetNewSeriesData(series.Id, series.Library.Type)); + // BackgroundJob.Enqueue(() => + // _externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type)); await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, series.LibraryId), false); @@ -213,9 +218,12 @@ public class ProcessSeries : IProcessSeries return; } - BackgroundJob.Enqueue(() => - _metadataService.GenerateCoversForSeries(series.LibraryId, series.Id, false, false)); - BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate)); + if (seriesAdded) + { + await _externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type); + } + await _metadataService.GenerateCoversForSeries(series.LibraryId, series.Id, false, false); + await _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate); } private async Task ReportDuplicateSeriesLookup(Library library, ParserInfo firstInfo, Exception ex) @@ -286,7 +294,7 @@ public class ProcessSeries : IProcessSeries var firstChapter = SeriesService.GetFirstChapterForMetadata(series); var firstFile = firstChapter?.Files.FirstOrDefault(); - if (firstFile == null || Parser.Parser.IsPdf(firstFile.FilePath)) return; + if (firstFile == null) return; var chapters = series.Volumes .SelectMany(volume => volume.Chapters) @@ -299,7 +307,19 @@ public class ProcessSeries : IProcessSeries } // Set the AgeRating as highest in all the comicInfos - if (!series.Metadata.AgeRatingLocked) series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); + if (!series.Metadata.AgeRatingLocked) + { + series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); + + // Get the MetadataSettings and apply Age Rating Mappings here + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title)); + var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings); + if (updatedRating > series.Metadata.AgeRating) + { + series.Metadata.AgeRating = updatedRating; + } + } DeterminePublicationStatus(series, chapters); @@ -319,86 +339,124 @@ public class ProcessSeries : IProcessSeries await UpdateCollectionTags(series, firstChapter); } - #region PeopleAndTagsAndGenres - if (!series.Metadata.WriterLocked) - { - var personSw = Stopwatch.StartNew(); - var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); - _logger.LogDebug("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count); - } + if (!series.Metadata.WriterLocked) + { + var personSw = Stopwatch.StartNew(); + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Writer)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); + } + _logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count); + } if (!series.Metadata.ColoristLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Colorist)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Colorist); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Colorist)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Colorist); + } } if (!series.Metadata.PublisherLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Publisher)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Publisher); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Publisher)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Publisher); + } } if (!series.Metadata.CoverArtistLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.CoverArtist)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.CoverArtist); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.CoverArtist)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.CoverArtist); + } } if (!series.Metadata.CharacterLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Character)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Character); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Character)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Character); + } } if (!series.Metadata.EditorLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Editor)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Editor); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Editor)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Editor); + } } if (!series.Metadata.InkerLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Inker)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Inker); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Inker)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Inker); + } } if (!series.Metadata.ImprintLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Imprint)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Imprint); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Imprint)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Imprint); + } } if (!series.Metadata.TeamLocked) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Team)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Team); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Team)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Team); + } } - if (!series.Metadata.LocationLocked) + if (!series.Metadata.LocationLocked && !series.Metadata.AllKavitaPlus(PersonRole.Location)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Location)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Location); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Location)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Location); + } } - if (!series.Metadata.LettererLocked) + if (!series.Metadata.LettererLocked && !series.Metadata.AllKavitaPlus(PersonRole.Letterer)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Letterer)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Letterer); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Location)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Letterer); + } } - if (!series.Metadata.PencillerLocked) + if (!series.Metadata.PencillerLocked && !series.Metadata.AllKavitaPlus(PersonRole.Penciller)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Penciller)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Penciller); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Penciller)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Penciller); + } } - if (!series.Metadata.TranslatorLocked) + if (!series.Metadata.TranslatorLocked && !series.Metadata.AllKavitaPlus(PersonRole.Translator)) { var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Translator)).ToList(); - await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Translator); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Translator)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Translator); + } } @@ -415,6 +473,35 @@ public class ProcessSeries : IProcessSeries } #endregion + + } + + /// + /// Ensure that we don't overwrite Person metadata when all metadata is coming from Kavita+ metadata match functionality + /// + /// + /// + /// + /// + private static bool ShouldUpdatePeopleForRole(Series series, List chapterPeople, PersonRole role) + { + if (chapterPeople.Count == 0) return false; + + // If metadata already has this role, but all entries are from KavitaPlus, we should retain them + if (series.Metadata.AnyOfRole(role)) + { + var existingPeople = series.Metadata.People.Where(p => p.Role == role); + + // If all existing people are KavitaPlus but new chapter people exist, we should still update + if (existingPeople.All(p => p.KavitaPlusConnection)) + { + return false; // Ensure we don't remove KavitaPlus people + } + + return true; // Default case: metadata exists, and it's okay to update + } + + return true; } private async Task UpdateCollectionTags(Series series, Chapter firstChapter) @@ -423,7 +510,7 @@ public class ProcessSeries : IProcessSeries var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections); if (defaultAdmin == null) return; - _logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name); + _logger.LogInformation("Collection tag(s) found for {SeriesName}, updating collections", series.Name); var sw = Stopwatch.StartNew(); foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) @@ -458,7 +545,7 @@ public class ProcessSeries : IProcessSeries await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(collectionTag); } - _logger.LogDebug("[TIME] Kavita took {Time} ms to process collections on Series: {Name}", sw.ElapsedMilliseconds, series.Name); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process collections on Series: {Name}", sw.ElapsedMilliseconds, series.Name); } @@ -542,8 +629,8 @@ public class ProcessSeries : IProcessSeries .Where(v => v.MaxNumber.IsNot(Parser.Parser.SpecialVolumeNumber)) .ToList(); - var maxVolume = (int) (nonSpecialVolumes.Any() ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); - var maxChapter = (int) chapters.Max(c => c.MaxNumber); + var maxVolume = (int)(nonSpecialVolumes.Any() ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); + var maxChapter = (int)chapters.Max(c => c.MaxNumber); // Single books usually don't have a number in their Range (filename) if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1) @@ -593,7 +680,6 @@ public class ProcessSeries : IProcessSeries { // Add new volumes and update chapters per volume var distinctVolumes = parsedInfos.DistinctVolumes(); - _logger.LogTrace("[ScannerService] Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name); foreach (var volumeNumber in distinctVolumes) { Volume? volume; @@ -621,7 +707,6 @@ public class ProcessSeries : IProcessSeries volume.LookupName = volumeNumber; volume.Name = volume.GetNumberTitle(); - _logger.LogTrace("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); await UpdateChapters(series, volume, infos, forceUpdate); @@ -641,7 +726,7 @@ public class ProcessSeries : IProcessSeries if (series.Volumes.Count == nonDeletedVolumes.Count) return; - _logger.LogTrace("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", + _logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", (series.Volumes.Count - nonDeletedVolumes.Count), series.Name); var deletedVolumes = series.Volumes.Except(nonDeletedVolumes); foreach (var volume in deletedVolumes) @@ -655,7 +740,7 @@ public class ProcessSeries : IProcessSeries file); } - _logger.LogTrace("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); + _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); } series.Volumes = nonDeletedVolumes; @@ -681,7 +766,7 @@ public class ProcessSeries : IProcessSeries if (chapter == null) { - _logger.LogTrace( + _logger.LogDebug( "[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters); chapter = ChapterBuilder.FromParserInfo(info).Build(); volume.Chapters.Add(chapter); @@ -706,7 +791,7 @@ public class ProcessSeries : IProcessSeries chapter.SortOrder = info.IssueOrder; } - if (float.TryParse(chapter.Title, out _)) + if (float.TryParse(chapter.Title, CultureInfo.InvariantCulture, out _)) { // If we have float based chapters, first scan can have the chapter formatted as Chapter 0.2 - .2 as the title is wrong. chapter.Title = chapter.GetNumberTitle(); @@ -724,78 +809,64 @@ public class ProcessSeries : IProcessSeries } RemoveChapters(volume, parsedInfos); - - // // Update all the metadata on the Chapters - // foreach (var chapter in volume.Chapters) - // { - // var firstFile = chapter.Files.MinBy(x => x.Chapter); - // if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) continue; - // try - // { - // var firstChapterInfo = infos.SingleOrDefault(i => i.FullFilePath.Equals(firstFile.FilePath)); - // await UpdateChapterFromComicInfo(chapter, firstChapterInfo?.ComicInfo, forceUpdate); - // } - // catch (Exception ex) - // { - // _logger.LogError(ex, "There was some issue when updating chapter's metadata"); - // } - // } } private void RemoveChapters(Volume volume, IList parsedInfos) { - // Remove chapters that aren't in parsedInfos or have no files linked + // Chapters to remove after enumeration + var chaptersToRemove = new List(); + var existingChapters = volume.Chapters; // Extract the directories (without filenames) from parserInfos var parsedDirectories = parsedInfos - .Select(p => Path.GetDirectoryName(p.FullFilePath)) // Get directory path + .Select(p => Path.GetDirectoryName(p.FullFilePath)) .Distinct() .ToList(); foreach (var existingChapter in existingChapters) { - // Get the directories for the files in the current chapter var chapterFileDirectories = existingChapter.Files - .Select(f => Path.GetDirectoryName(f.FilePath)) // Get directory path minus the filename + .Select(f => Path.GetDirectoryName(f.FilePath)) .Distinct() .ToList(); - // Check if any of the chapter's file directories match the parsedDirectories var hasMatchingDirectory = chapterFileDirectories.Exists(dir => parsedDirectories.Contains(dir)); if (hasMatchingDirectory) { - // Ensure we remove any files that no longer exist AND order the remaining files existingChapter.Files = existingChapter.Files .Where(f => parsedInfos.Any(p => Parser.Parser.NormalizePath(p.FullFilePath) == Parser.Parser.NormalizePath(f.FilePath))) .OrderByNatural(f => f.FilePath) .ToList(); - // Update the chapter's page count after filtering the files existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages); - // If no files remain after filtering, remove the chapter if (existingChapter.Files.Count != 0) continue; - _logger.LogTrace("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); - volume.Chapters.Remove(existingChapter); + chaptersToRemove.Add(existingChapter); // Mark chapter for removal } else { - // If there are no matching directories in the current scan, check if the files still exist on disk var filesExist = existingChapter.Files.Any(f => File.Exists(f.FilePath)); - - // If no files exist, remove the chapter if (filesExist) continue; - _logger.LogTrace("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName} as no files exist", + + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName} as no files exist", existingChapter.Range, volume.Name, parsedInfos[0].Series); - volume.Chapters.Remove(existingChapter); + chaptersToRemove.Add(existingChapter); // Mark chapter for removal } } + + // Remove chapters after the loop to avoid modifying the collection during enumeration + foreach (var chapter in chaptersToRemove) + { + volume.Chapters.Remove(chapter); + } } + private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false) { chapter.Files ??= new List(); @@ -803,13 +874,18 @@ public class ProcessSeries : IProcessSeries var fileInfo = _directoryService.FileSystem.FileInfo.New(info.FullFilePath); if (existingFile != null) { + // TODO: I wonder if we can simplify this force check. existingFile.Format = info.Format; + if (!forceUpdate && !_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return; + existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format); existingFile.Extension = fileInfo.Extension.ToLowerInvariant(); existingFile.FileName = Parser.Parser.RemoveExtensionIfSupported(existingFile.FilePath); existingFile.FilePath = Parser.Parser.NormalizePath(existingFile.FilePath); existingFile.Bytes = fileInfo.Length; + existingFile.KoreaderHash = KoreaderHelper.HashContents(existingFile.FilePath); + // We skip updating DB here with last modified time so that metadata refresh can do it } else @@ -818,6 +894,7 @@ public class ProcessSeries : IProcessSeries var file = new MangaFileBuilder(info.FullFilePath, info.Format, _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format)) .WithExtension(fileInfo.Extension) .WithBytes(fileInfo.Length) + .WithHash() .Build(); chapter.Files.Add(file); } @@ -911,82 +988,82 @@ public class ProcessSeries : IProcessSeries chapter.ReleaseDate = new DateTime(comicInfo.Year, month, day); } - if (!chapter.ColoristLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Colorist)) { var people = TagHelper.GetTagValues(comicInfo.Colorist); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Colorist); } - if (!chapter.CharacterLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Character)) { var people = TagHelper.GetTagValues(comicInfo.Characters); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Character); } - if (!chapter.TranslatorLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Translator)) { var people = TagHelper.GetTagValues(comicInfo.Translator); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Translator); } - if (!chapter.WriterLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Writer)) { var personSw = Stopwatch.StartNew(); var people = TagHelper.GetTagValues(comicInfo.Writer); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Writer); - _logger.LogDebug("[TIME] Kavita took {Time} ms to process writer on Chapter: {File} for {Count} people", personSw.ElapsedMilliseconds, chapter.Files.First().FileName, people.Count); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Chapter: {File} for {Count} people", personSw.ElapsedMilliseconds, chapter.Files.First().FileName, people.Count); } - if (!chapter.EditorLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Editor)) { var people = TagHelper.GetTagValues(comicInfo.Editor); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Editor); } - if (!chapter.InkerLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Inker)) { var people = TagHelper.GetTagValues(comicInfo.Inker); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Inker); } - if (!chapter.LettererLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Letterer)) { var people = TagHelper.GetTagValues(comicInfo.Letterer); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Letterer); } - if (!chapter.PencillerLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Penciller)) { var people = TagHelper.GetTagValues(comicInfo.Penciller); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Penciller); } - if (!chapter.CoverArtistLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.CoverArtist)) { var people = TagHelper.GetTagValues(comicInfo.CoverArtist); await UpdateChapterPeopleAsync(chapter, people, PersonRole.CoverArtist); } - if (!chapter.PublisherLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Publisher)) { var people = TagHelper.GetTagValues(comicInfo.Publisher); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Publisher); } - if (!chapter.ImprintLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Imprint)) { var people = TagHelper.GetTagValues(comicInfo.Imprint); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Imprint); } - if (!chapter.TeamLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Team)) { var people = TagHelper.GetTagValues(comicInfo.Teams); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Team); } - if (!chapter.LocationLocked) + if (!chapter.IsPersonRoleLocked(PersonRole.Location)) { var people = TagHelper.GetTagValues(comicInfo.Locations); await UpdateChapterPeopleAsync(chapter, people, PersonRole.Location); @@ -1004,7 +1081,7 @@ public class ProcessSeries : IProcessSeries await UpdateChapterTags(chapter, tags); } - _logger.LogDebug("[TIME] Kavita took {Time} ms to create/update Chapter: {File}", sw.ElapsedMilliseconds, chapter.Files.First().FileName); + _logger.LogTrace("[TIME] Kavita took {Time} ms to create/update Chapter: {File}", sw.ElapsedMilliseconds, chapter.Files.First().FileName); } private async Task UpdateChapterGenres(Chapter chapter, IEnumerable genreNames) diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 3795ed8db..cb5f4302f 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -161,7 +161,7 @@ public class ScannerService : IScannerService { if (TaskScheduler.HasScanTaskRunningForSeries(series.Id)) { - _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); + _logger.LogTrace("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); return; } @@ -186,7 +186,7 @@ public class ScannerService : IScannerService { if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id)) { - _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); + _logger.LogTrace("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); return; } BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1)); @@ -317,7 +317,7 @@ public class ScannerService : IScannerService // Process Series var seriesProcessStopWatch = Stopwatch.StartNew(); await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks); - _logger.LogDebug("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, parsedSeries[pSeries][0].Series); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, parsedSeries[pSeries][0].Series); seriesLeftToProcess--; } @@ -335,11 +335,21 @@ public class ScannerService : IScannerService private static Dictionary> TrackFoundSeriesAndFiles(IList seenSeries) { + // Why does this only grab things that have changed? var parsedSeries = new Dictionary>(); - foreach (var series in seenSeries.Where(s => s.ParsedInfos.Count > 0 && s.HasChanged)) + foreach (var series in seenSeries.Where(s => s.ParsedInfos.Count > 0)) // && s.HasChanged { var parsedFiles = series.ParsedInfos; - parsedSeries.Add(series.ParsedSeries, parsedFiles); + series.ParsedSeries.HasChanged = series.HasChanged; + + if (series.HasChanged) + { + parsedSeries.Add(series.ParsedSeries, parsedFiles); + } + else + { + parsedSeries.Add(series.ParsedSeries, []); + } } return parsedSeries; @@ -450,12 +460,12 @@ public class ScannerService : IScannerService // That way logging and UI informing is all in one place with full context _logger.LogError("[ScannerService] Some of the root folders for the library are empty. " + "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan has be aborted. " + + "Scan has been aborted. " + "Check that your mount is connected or change the library's root folder and rescan"); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent( $"Some of the root folders for the library, {libraryName}, are empty.", "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan has be aborted. " + + "Scan has been aborted. " + "Check that your mount is connected or change the library's root folder and rescan")); return false; @@ -511,11 +521,16 @@ public class ScannerService : IScannerService // Validations are done, now we can start actual scan _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); + if (!library.EnableMetadata) + { + _logger.LogInformation("[ScannerService] Warning! {LibraryName} has metadata turned off", library.Name); + } + // This doesn't work for something like M:/Manga/ and a series has library folder as root var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths)); if (!shouldUseLibraryScan) { - _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders, using series scan", library.Name); + _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders as a library root, using series scan", library.Name); } @@ -601,6 +616,12 @@ public class ScannerService : IScannerService foreach (var series in parsedSeries) { + if (!series.Key.HasChanged) + { + _logger.LogDebug("{Series} hasn't changed", series.Key.Name); + continue; + } + // Filter out ParserInfos where FullFilePath is empty (i.e., folder not modified) var validInfos = series.Value.Where(info => !string.IsNullOrEmpty(info.Filename)).ToList(); @@ -644,7 +665,7 @@ public class ScannerService : IScannerService totalFiles += pSeries.Value.Count; var seriesProcessStopWatch = Stopwatch.StartNew(); await _processSeries.ProcessSeriesAsync(pSeries.Value, library, seriesLeftToProcess, forceUpdate); - _logger.LogDebug("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, pSeries.Value[0].Series); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, pSeries.Value[0].Series); seriesLeftToProcess--; } diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index aaa06a846..3dca14ab9 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -2,12 +2,14 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json.Serialization; using System.Threading.Tasks; using API.Data; using API.DTOs.Theme; using API.Entities; using API.Entities.Enums.Theme; using API.Extensions; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Flurl.Http; using HtmlAgilityPack; @@ -32,6 +34,7 @@ internal class GitHubContent [JsonProperty("type")] public string Type { get; set; } + [JsonPropertyName("download_url")] [JsonProperty("download_url")] public string DownloadUrl { get; set; } @@ -151,6 +154,7 @@ public class ThemeService : IThemeService // Fetch contents of the theme directory var themeContents = await GetDirectoryContent(themeDir.Path); + // Find css and preview files var cssFile = themeContents.FirstOrDefault(c => c.Name.EndsWith(".css")); var previewUrls = GetPreviewUrls(themeContents); @@ -187,19 +191,22 @@ public class ThemeService : IThemeService return themeDtos; } - private static IList GetPreviewUrls(IEnumerable themeContents) + private static List GetPreviewUrls(IEnumerable themeContents) { - return themeContents.Where(c => c.Name.ToLower().EndsWith(".jpg") || c.Name.ToLower().EndsWith(".png") ) + return themeContents + .Where(c => Parser.IsImage(c.Name) ) .Select(p => p.DownloadUrl) .ToList(); } private static async Task> GetDirectoryContent(string path) { - return await $"{GithubBaseUrl}/repos/Kareadita/Themes/contents/{path}" + var json = await $"{GithubBaseUrl}/repos/Kareadita/Themes/contents/{path}" .WithHeader("Accept", "application/vnd.github+json") .WithHeader("User-Agent", "Kavita") - .GetJsonAsync>(); + .GetStringAsync(); + + return string.IsNullOrEmpty(json) ? [] : JsonConvert.DeserializeObject>(json); } /// @@ -295,7 +302,8 @@ public class ThemeService : IThemeService var existingThemes = _directoryService.ScanFiles(_directoryService.SiteThemeDirectory, string.Empty); if (existingThemes.Any(f => Path.GetFileName(f) == dto.CssFile)) { - throw new KavitaException("Cannot download file, file already on disk"); + // This can happen if you delete then immediately download (to refresh). We should just delete the old file and download. Users can always rollback their version with github directly + _directoryService.DeleteFiles(existingThemes.Where(f => Path.GetFileName(f) == dto.CssFile)); } var finalLocation = await DownloadSiteTheme(dto); diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 9cc93fefd..5d5df6647 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -1,20 +1,29 @@ using System; using System.Collections.Generic; -using System.IO; +using System.Diagnostics; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; using System.Threading.Tasks; using API.Data; +using API.Data.Misc; using API.Data.Repositories; using API.DTOs.Stats; +using API.DTOs.Stats.V3; +using API.Entities; using API.Entities.Enums; -using API.Entities.Enums.UserPreferences; +using API.Extensions; +using API.Services.Plus; +using API.Services.Tasks.Scanner.Parser; using Flurl.Http; +using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Services.Tasks; @@ -24,7 +33,6 @@ namespace API.Services.Tasks; public interface IStatsService { Task Send(); - Task GetServerInfo(); Task GetServerInfoSlim(); Task SendCancellation(); } @@ -36,23 +44,33 @@ public class StatsService : IStatsService private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly DataContext _context; - private readonly IStatisticService _statisticService; - private const string ApiUrl = "https://stats.kavitareader.com"; + private readonly ILicenseService _licenseService; + private readonly UserManager _userManager; + private readonly IEmailService _emailService; + private readonly ICacheService _cacheService; + private readonly string _apiUrl = ""; + private const string ApiKey = "MsnvA2DfQqxSK5jh"; // It's not important this is public, just a way to keep bots from hitting the API willy-nilly - public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService) + public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context, + ILicenseService licenseService, UserManager userManager, IEmailService emailService, + ICacheService cacheService, IHostEnvironment environment) { _logger = logger; _unitOfWork = unitOfWork; _context = context; - _statisticService = statisticService; + _licenseService = licenseService; + _userManager = userManager; + _emailService = emailService; + _cacheService = cacheService; - FlurlHttp.ConfigureClient(ApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.StatsApiUrl); + + _apiUrl = environment.IsDevelopment() ? "http://localhost:5001" : Configuration.StatsApiUrl; } /// /// Due to all instances firing this at the same time, we can DDOS our server. This task when fired will schedule the task to be run - /// randomly over a 6 hour spread + /// randomly over a six-hour spread /// public async Task Send() { @@ -71,24 +89,22 @@ public class StatsService : IStatsService // ReSharper disable once MemberCanBePrivate.Global public async Task SendData() { - var data = await GetServerInfo(); + var sw = Stopwatch.StartNew(); + var data = await GetStatV3Payload(); + _logger.LogDebug("Collecting stats took {Time} ms", sw.ElapsedMilliseconds); + sw.Stop(); await SendDataToStatsServer(data); } - private async Task SendDataToStatsServer(ServerInfoDto data) + private async Task SendDataToStatsServer(ServerInfoV3Dto data) { var responseContent = string.Empty; try { - var response = await (ApiUrl + "/api/v2/stats") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(30)) + var response = await (_apiUrl + "/api/v3/stats") + .WithBasicHeaders(ApiKey) .PostJsonAsync(data); if (response.StatusCode != StatusCodes.Status200OK) @@ -112,67 +128,6 @@ public class StatsService : IStatsService } } - public async Task GetServerInfo() - { - var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - - var serverInfo = new ServerInfoDto - { - InstallId = serverSettings.InstallId, - Os = RuntimeInformation.OSDescription, - KavitaVersion = serverSettings.InstallVersion, - DotnetVersion = Environment.Version.ToString(), - IsDocker = OsInfo.IsDocker, - NumOfCores = Math.Max(Environment.ProcessorCount, 1), - UsersWithEmulateComicBook = await _context.AppUserPreferences.CountAsync(p => p.EmulateBook), - TotalReadingHours = await _statisticService.TimeSpentReadingForUsersAsync(ArraySegment.Empty, ArraySegment.Empty), - - PercentOfLibrariesWithFolderWatchingEnabled = await GetPercentageOfLibrariesWithFolderWatchingEnabled(), - PercentOfLibrariesIncludedInRecommended = await GetPercentageOfLibrariesIncludedInRecommended(), - PercentOfLibrariesIncludedInDashboard = await GetPercentageOfLibrariesIncludedInDashboard(), - PercentOfLibrariesIncludedInSearch = await GetPercentageOfLibrariesIncludedInSearch(), - - HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(), - NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(), - NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()).Count(), - NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(), - OPDSEnabled = serverSettings.EnableOpds, - NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Count(), - TotalFiles = await _unitOfWork.LibraryRepository.GetTotalFiles(), - TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(), - TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(), - UsingSeriesRelationships = await GetIfUsingSeriesRelationship(), - EncodeMediaAs = serverSettings.EncodeMediaAs, - MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(), - MaxVolumesInASeries = await MaxVolumesInASeries(), - MaxChaptersInASeries = await MaxChaptersInASeries(), - MangaReaderBackgroundColors = await AllMangaReaderBackgroundColors(), - MangaReaderPageSplittingModes = await AllMangaReaderPageSplitting(), - MangaReaderLayoutModes = await AllMangaReaderLayoutModes(), - FileFormats = AllFormats(), - UsingRestrictedProfiles = await GetUsingRestrictedProfiles(), - LastReadTime = await _unitOfWork.AppUserProgressRepository.GetLatestProgress() - }; - - var usersWithPref = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences)).ToList(); - serverInfo.UsersOnCardLayout = - usersWithPref.Count(u => u.UserPreferences.GlobalPageLayoutMode == PageLayoutMode.Cards); - serverInfo.UsersOnListLayout = - usersWithPref.Count(u => u.UserPreferences.GlobalPageLayoutMode == PageLayoutMode.List); - - var firstAdminUser = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).FirstOrDefault(); - - if (firstAdminUser != null) - { - var firstAdminUserPref = (await _unitOfWork.UserRepository.GetPreferencesAsync(firstAdminUser.UserName!)); - var activeTheme = firstAdminUserPref?.Theme ?? Seed.DefaultThemes.First(t => t.IsDefault); - - serverInfo.ActiveSiteTheme = activeTheme.Name; - if (firstAdminUserPref != null) serverInfo.MangaReaderMode = firstAdminUserPref.ReaderMode; - } - - return serverInfo; - } public async Task GetServerInfoSlim() { @@ -196,12 +151,8 @@ public class StatsService : IStatsService try { - var response = await (ApiUrl + "/api/v2/stats/opt-out?installId=" + installId) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") + var response = await (_apiUrl + "/api/v2/stats/opt-out?installId=" + installId) + .WithBasicHeaders(ApiKey) .WithTimeout(TimeSpan.FromSeconds(30)) .PostAsync(); @@ -220,42 +171,32 @@ public class StatsService : IStatsService } } - private async Task GetPercentageOfLibrariesWithFolderWatchingEnabled() + private static async Task PingStatsApi() { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.FolderWatching) / (1.0f * libraries.Count); - } + try + { + var sw = Stopwatch.StartNew(); + var response = await (Configuration.StatsApiUrl + "/api/health/") + .WithBasicHeaders(ApiKey) + .GetAsync(); - private async Task GetPercentageOfLibrariesIncludedInRecommended() - { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.IncludeInRecommended) / (1.0f * libraries.Count); - } + if (response.StatusCode == StatusCodes.Status200OK) + { + sw.Stop(); + return sw.ElapsedMilliseconds; + } + } + catch (Exception) + { + /* Swallow */ + } - private async Task GetPercentageOfLibrariesIncludedInDashboard() - { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.IncludeInDashboard) / (1.0f * libraries.Count); - } - - private async Task GetPercentageOfLibrariesIncludedInSearch() - { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.IncludeInSearch) / (1.0f * libraries.Count); - } - - private Task GetIfUsingSeriesRelationship() - { - return _context.SeriesRelation.AnyAsync(); + return 0; } private async Task MaxSeriesInAnyLibrary() { - // If first time flow, just return 0 + // If first time flow, return 0 if (!await _context.Series.AnyAsync()) return 0; return await _context.Series .Select(s => _context.Library.Where(l => l.Id == s.LibraryId).SelectMany(l => l.Series!).Count()) @@ -281,50 +222,191 @@ public class StatsService : IStatsService { // If first time flow, just return 0 if (!await _context.Chapter.AnyAsync()) return 0; + return await _context.Series .AsNoTracking() .AsSplitQuery() .MaxAsync(s => s.Volumes! - .Where(v => v.MinNumber == 0) + .Where(v => v.MinNumber == Parser.LooseLeafVolumeNumber) .SelectMany(v => v.Chapters!) .Count()); } - private async Task> AllMangaReaderBackgroundColors() + private async Task GetStatV3Payload() { - return await _context.AppUserPreferences.Select(p => p.BackgroundColor).Distinct().ToListAsync(); - } + var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var mediaSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + var dto = new ServerInfoV3Dto() + { + InstallId = serverSettings.InstallId, + KavitaVersion = serverSettings.InstallVersion, + InitialKavitaVersion = serverSettings.FirstInstallVersion, + InitialInstallDate = (DateTime)serverSettings.FirstInstallDate!, + IsDocker = OsInfo.IsDocker, + Os = RuntimeInformation.OSDescription, + NumOfCores = Math.Max(Environment.ProcessorCount, 1), + DotnetVersion = Environment.Version.ToString(), + OpdsEnabled = serverSettings.EnableOpds, + EncodeMediaAs = serverSettings.EncodeMediaAs, + MatchedMetadataEnabled = mediaSettings.Enabled + }; - private async Task> AllMangaReaderPageSplitting() - { - return await _context.AppUserPreferences.Select(p => p.PageSplitOption).Distinct().ToListAsync(); - } + dto.OsLocale = CultureInfo.CurrentCulture.EnglishName; + dto.LastReadTime = await _unitOfWork.AppUserProgressRepository.GetLatestProgress(); + dto.MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(); + dto.MaxVolumesInASeries = await MaxVolumesInASeries(); + dto.MaxChaptersInASeries = await MaxChaptersInASeries(); + dto.TotalFiles = await _context.MangaFile.CountAsync(); + dto.TotalGenres = await _context.Genre.CountAsync(); + dto.TotalPeople = await _context.Person.CountAsync(); + dto.TotalSeries = await _context.Series.CountAsync(); + dto.TotalLibraries = await _context.Library.CountAsync(); + dto.NumberOfCollections = await _context.AppUserCollection.CountAsync(); + dto.NumberOfReadingLists = await _context.ReadingList.CountAsync(); + + try + { + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + dto.ActiveKavitaPlusSubscription = await _licenseService.HasActiveSubscription(license); + } + catch (Exception) + { + dto.ActiveKavitaPlusSubscription = false; + } - private async Task> AllMangaReaderLayoutModes() - { - return await _context.AppUserPreferences.Select(p => p.LayoutMode).Distinct().ToListAsync(); - } + // Find a random cbz/zip file and open it for reading + await OpenRandomFile(dto); + dto.TimeToPingKavitaStatsApi = await PingStatsApi(); - private IEnumerable AllFormats() - { + #region Relationships - var results = _context.MangaFile - .AsNoTracking() - .AsEnumerable() - .Select(m => new FileFormatDto() + dto.Relationships = await _context.SeriesRelation + .GroupBy(sr => sr.RelationKind) + .Select(g => new RelationshipStatV3 { - Format = m.Format, - Extension = m.Extension + Relationship = g.Key, + Count = g.Count() }) - .DistinctBy(f => f.Extension) - .ToList(); + .ToListAsync(); - return results; + #endregion + + #region Libraries + var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.Folders | + LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns | LibraryIncludes.AppUser)).ToList(); + dto.Libraries ??= []; + foreach (var library in allLibraries) + { + var libDto = new LibraryStatV3(); + libDto.IncludeInDashboard = library.IncludeInDashboard; + libDto.IncludeInSearch = library.IncludeInSearch; + libDto.LastScanned = library.LastScanned; + libDto.NumberOfFolders = library.Folders.Count; + libDto.FileTypes = library.LibraryFileTypes.Select(s => s.FileTypeGroup).Distinct().ToList(); + libDto.UsingExcludePatterns = library.LibraryExcludePatterns.Any(p => !string.IsNullOrEmpty(p.Pattern)); + libDto.UsingFolderWatching = library.FolderWatching; + libDto.CreateCollectionsFromMetadata = library.ManageCollections; + libDto.CreateReadingListsFromMetadata = library.ManageReadingLists; + libDto.LibraryType = library.Type; + + dto.Libraries.Add(libDto); + } + #endregion + + #region Users + + // Create a dictionary mapping user IDs to the libraries they have access to + var userLibraryAccess = allLibraries + .SelectMany(l => l.AppUsers.Select(appUser => new { l, appUser.Id })) + .GroupBy(x => x.Id) + .ToDictionary(g => g.Key, g => g.Select(x => x.l).ToList()); + dto.Users ??= []; + var allUsers = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences + | AppUserIncludes.ReadingLists | AppUserIncludes.Bookmarks + | AppUserIncludes.Collections | AppUserIncludes.Devices + | AppUserIncludes.Progress | AppUserIncludes.Ratings + | AppUserIncludes.SmartFilters | AppUserIncludes.WantToRead, false); + foreach (var user in allUsers) + { + var userDto = new UserStatV3(); + userDto.HasMALToken = !string.IsNullOrEmpty(user.MalAccessToken); + userDto.HasAniListToken = !string.IsNullOrEmpty(user.AniListAccessToken); + userDto.AgeRestriction = new AgeRestriction() + { + AgeRating = user.AgeRestriction, + IncludeUnknowns = user.AgeRestrictionIncludeUnknowns + }; + + userDto.Locale = user.UserPreferences.Locale; + userDto.Roles = [.. _userManager.GetRolesAsync(user).Result]; + userDto.LastLogin = user.LastActiveUtc; + userDto.HasValidEmail = user.Email != null && _emailService.IsValidEmail(user.Email); + userDto.IsEmailConfirmed = user.EmailConfirmed; + userDto.ActiveTheme = user.UserPreferences.Theme.Name; + userDto.CollectionsCreatedCount = user.Collections.Count; + userDto.ReadingListsCreatedCount = user.ReadingLists.Count; + userDto.LastReadTime = user.Progresses + .Select(p => p.LastModifiedUtc) + .DefaultIfEmpty() + .Max(); + userDto.DevicePlatforms = user.Devices.Select(d => d.Platform).ToList(); + userDto.SeriesBookmarksCreatedCount = user.Bookmarks.Count; + userDto.SmartFilterCreatedCount = user.SmartFilters.Count; + userDto.WantToReadSeriesCount = user.WantToRead.Count; + + if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries)) + { + userDto.PercentageOfLibrariesHasAccess = (1f * accessibleLibraries.Count) / allLibraries.Count; + } + else + { + userDto.PercentageOfLibrariesHasAccess = 0; + } + + dto.Users.Add(userDto); + } + + #endregion + + return dto; } - private Task GetUsingRestrictedProfiles() + private async Task OpenRandomFile(ServerInfoV3Dto dto) { - return _context.Users.AnyAsync(u => u.AgeRestriction > AgeRating.NotApplicable); + var random = new Random(); + List extensions = [".cbz", ".zip"]; + + // Count the total number of files that match the criteria + var count = await _context.MangaFile.AsNoTracking() + .Where(r => r.Extension != null && extensions.Contains(r.Extension)) + .CountAsync(); + + if (count == 0) + { + dto.TimeToOpeCbzMs = 0; + dto.TimeToOpenCbzPages = 0; + + return; + } + + // Generate a random skip value + var skip = random.Next(count); + + // Fetch the random file + var randomFile = await _context.MangaFile.AsNoTracking() + .Where(r => r.Extension != null && extensions.Contains(r.Extension)) + .Skip(skip) + .Take(1) + .FirstAsync(); + + var sw = Stopwatch.StartNew(); + + await _cacheService.Ensure(randomFile.ChapterId); + var time = sw.ElapsedMilliseconds; + sw.Stop(); + + dto.TimeToOpeCbzMs = time; + dto.TimeToOpenCbzPages = randomFile.Pages; } } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index f1a6eb383..4ccf79abb 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -1,8 +1,13 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.DTOs.Update; +using API.Extensions; using API.SignalR; using Flurl.Http; using Kavita.Common.EnvironmentInfo; @@ -30,7 +35,7 @@ internal class GithubReleaseMetadata /// public required string Body { get; init; } /// - /// Url of the release on Github + /// Url of the release on GitHub /// // ReSharper disable once InconsistentNaming public required string Html_Url { get; init; } @@ -45,11 +50,13 @@ public interface IVersionUpdaterService { Task CheckForUpdate(); Task PushUpdate(UpdateNotificationDto update); - Task> GetAllReleases(); - Task GetNumberOfReleasesBehind(); + Task> GetAllReleases(int count = 0); + Task GetNumberOfReleasesBehind(bool stableOnly = false); + void BustGithubCache(); } -public class VersionUpdaterService : IVersionUpdaterService + +public partial class VersionUpdaterService : IVersionUpdaterService { private readonly ILogger _logger; private readonly IEventHub _eventHub; @@ -57,37 +64,253 @@ public class VersionUpdaterService : IVersionUpdaterService #pragma warning disable S1075 private const string GithubLatestReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases/latest"; private const string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases"; + private const string GithubPullsUrl = "https://api.github.com/repos/Kareadita/Kavita/pulls/"; + private const string GithubBranchCommitsUrl = "https://api.github.com/repos/Kareadita/Kavita/commits?sha=develop"; #pragma warning restore S1075 - public VersionUpdaterService(ILogger logger, IEventHub eventHub) + [GeneratedRegex(@"^\n*(.*?)\n+#{1,2}\s", RegexOptions.Singleline)] + private static partial Regex BlogPartRegex(); + private readonly string _cacheFilePath; + /// + /// The latest release cache + /// + private readonly string _cacheLatestReleaseFilePath; + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public VersionUpdaterService(ILogger logger, IEventHub eventHub, IDirectoryService directoryService) { _logger = logger; _eventHub = eventHub; + _cacheFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_releases_cache.json"); + _cacheLatestReleaseFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_latest_release_cache.json"); - FlurlHttp.ConfigureClient(GithubLatestReleasesUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - FlurlHttp.ConfigureClient(GithubAllReleasesUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(GithubLatestReleasesUrl); + FlurlConfiguration.ConfigureClientForUrl(GithubAllReleasesUrl); } /// - /// Fetches the latest release from Github + /// Fetches the latest (stable) release from GitHub. Does not do any extra nightly release parsing. /// /// Latest update public async Task CheckForUpdate() { + // Attempt to fetch from cache + var cachedRelease = await TryGetCachedLatestRelease(); + if (cachedRelease != null) + { + return cachedRelease; + } + var update = await GetGithubRelease(); - return CreateDto(update); + var dto = CreateDto(update); + + if (dto != null) + { + await CacheLatestReleaseAsync(dto); + } + + return dto; } - public async Task> GetAllReleases() + /// + /// Will add any extra (nightly) updates from the latest stable. Does not back-fill anything prior to the latest stable. + /// + /// + private async Task EnrichWithNightlyInfo(List dtos) { + var dto = dtos[0]; // Latest version + try + { + var currentVersion = new Version(dto.CurrentVersion); + var nightlyReleases = await GetNightlyReleases(currentVersion, Version.Parse(dto.UpdateVersion)); + + if (nightlyReleases.Count == 0) return; + + // Create new DTOs for each nightly release and insert them at the beginning of the list + var nightlyDtos = new List(); + foreach (var nightly in nightlyReleases) + { + var prInfo = await FetchPullRequestInfo(nightly.PrNumber); + if (prInfo == null) continue; + + var sections = ParseReleaseBody(prInfo.Body); + var blogPart = ExtractBlogPart(prInfo.Body); + + var nightlyDto = new UpdateNotificationDto + { + // TODO: I should pass Title to the FE so that Nightly Release can be localized + UpdateTitle = $"Nightly Release {nightly.Version} - {prInfo.Title}", + UpdateVersion = nightly.Version, + CurrentVersion = dto.CurrentVersion, + UpdateUrl = prInfo.Html_Url, + PublishDate = prInfo.Merged_At, + IsDocker = true, // Nightlies are always Docker Only + IsReleaseEqual = IsVersionEqualToBuildVersion(Version.Parse(nightly.Version)), + IsReleaseNewer = true, // Since we already filtered these in GetNightlyReleases + IsPrerelease = true, // All Nightlies are considered prerelease + Added = sections.TryGetValue("Added", out var added) ? added : [], + Changed = sections.TryGetValue("Changed", out var changed) ? changed : [], + Fixed = sections.TryGetValue("Fixed", out var bugfixes) ? bugfixes : [], + Removed = sections.TryGetValue("Removed", out var removed) ? removed : [], + Theme = sections.TryGetValue("Theme", out var theme) ? theme : [], + Developer = sections.TryGetValue("Developer", out var developer) ? developer : [], + KnownIssues = sections.TryGetValue("KnownIssues", out var knownIssues) ? knownIssues : [], + Api = sections.TryGetValue("Api", out var api) ? api : [], + FeatureRequests = sections.TryGetValue("Feature Requests", out var frs) ? frs : [], + BlogPart = _markdown.Transform(blogPart.Trim()), + UpdateBody = _markdown.Transform(prInfo.Body.Trim()) + }; + + nightlyDtos.Add(nightlyDto); + } + + // Insert nightly releases at the beginning of the list + var sortedNightlyDtos = nightlyDtos.OrderByDescending(x => x.PublishDate).ToList(); + dtos.InsertRange(0, sortedNightlyDtos); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to enrich nightly release information"); + } + } + + + private async Task FetchPullRequestInfo(int prNumber) + { + try + { + return await $"{GithubPullsUrl}{prNumber}" + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .GetJsonAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch PR information for #{PrNumber}", prNumber); + return null; + } + } + + private async Task> GetNightlyReleases(Version currentVersion, Version latestStableVersion) + { + try + { + var nightlyReleases = new List(); + + var commits = await GithubBranchCommitsUrl + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .GetJsonAsync>(); + + var commitList = commits.ToList(); + bool foundLastStable = false; + + for (var i = 0; i < commitList.Count - 1; i++) + { + var commit = commitList[i]; + var message = commit.Commit.Message.Split('\n')[0]; // Take first line only + + // Skip [skip ci] commits + if (message.Contains("[skip ci]")) continue; + + // Check if this is a stable release + if (message.StartsWith('v')) + { + var stableMatch = Regex.Match(message, @"v(\d+\.\d+\.\d+\.\d+)"); + if (stableMatch.Success) + { + var stableVersion = new Version(stableMatch.Groups[1].Value); + // If we find a stable version lower than current, we've gone too far back + if (stableVersion <= currentVersion) + { + foundLastStable = true; + break; + } + } + continue; + } + + // Look for version bumps that follow PRs + if (!foundLastStable && message == "Bump versions by dotnet-bump-version.") + { + // Get the PR commit that triggered this version bump + if (i + 1 < commitList.Count) + { + var prCommit = commitList[i + 1]; + var prMessage = prCommit.Commit.Message.Split('\n')[0]; + + // Extract PR number using improved regex + var prMatch = Regex.Match(prMessage, @"(?:^|\s)\(#(\d+)\)|\s#(\d+)"); + if (!prMatch.Success) continue; + + var prNumber = int.Parse(prMatch.Groups[1].Value != "" ? + prMatch.Groups[1].Value : prMatch.Groups[2].Value); + + // Get the version from AssemblyInfo.cs in this commit + var version = await GetVersionFromCommit(commit.Sha); + if (version == null) continue; + + // Parse version and compare with current version + if (Version.TryParse(version, out var parsedVersion) && + parsedVersion > latestStableVersion) + { + nightlyReleases.Add(new NightlyInfo + { + Version = version, + PrNumber = prNumber, + Date = DateTime.Parse(commit.Commit.Author.Date, CultureInfo.InvariantCulture) + }); + } + } + } + } + + return nightlyReleases.OrderByDescending(x => x.Date).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get nightly releases"); + return []; + } + } + + public async Task> GetAllReleases(int count = 0) + { + // Attempt to fetch from cache + var cachedReleases = await TryGetCachedReleases(); + // If there is a cached release and the current version is within it, use it, otherwise regenerate + if (cachedReleases != null && cachedReleases.Any(r => IsVersionEqual(r.UpdateVersion, BuildInfo.Version.ToString()))) + { + if (count > 0) + { + // NOTE: We may want to allow the admin to clear Github cache + return cachedReleases.Take(count).ToList(); + } + + return cachedReleases; + } + var updates = await GetGithubReleases(); - var updateDtos = updates.Select(CreateDto) + var query = updates.Select(CreateDto) .Where(d => d != null) .OrderByDescending(d => d!.PublishDate) - .Select(d => d!) - .ToList(); + .Select(d => d!); + + var updateDtos = query.ToList(); + + // Sometimes a release can be 0.8.5.0 on disk, but 0.8.5 from Github + var versionParts = updateDtos[0].UpdateVersion.Split('.'); + if (versionParts.Length < 4) + { + updateDtos[0].UpdateVersion += ".0"; // Append missing parts + } + + // If we're on a nightly build, enrich the information + if (updateDtos.Count != 0) // && BuildInfo.Version > new Version(updateDtos[0].UpdateVersion) + { + await EnrichWithNightlyInfo(updateDtos); + } // Find the latest dto var latestRelease = updateDtos[0]!; @@ -103,30 +326,140 @@ public class VersionUpdaterService : IVersionUpdaterService latestRelease.IsOnNightlyInRelease = isNightly; + // Cache the fetched data + if (updateDtos.Count > 0) + { + await CacheReleasesAsync(updateDtos); + } + + if (count > 0) + { + return updateDtos.Take(count).ToList(); + } + return updateDtos; } + /// + /// Compares 2 versions and ensures that the minor is always there + /// + /// + /// + /// + private static bool IsVersionEqual(string v1, string v2) + { + var versionParts = v1.Split('.'); + if (versionParts.Length < 4) + { + v1 += ".0"; // Append missing parts + } + + versionParts = v2.Split('.'); + if (versionParts.Length < 4) + { + v2 += ".0"; // Append missing parts + } + + return string.Equals(v2, v2, StringComparison.OrdinalIgnoreCase); + } + + private async Task?> TryGetCachedReleases() + { + if (!File.Exists(_cacheFilePath)) return null; + + var fileInfo = new FileInfo(_cacheFilePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + var cachedData = await File.ReadAllTextAsync(_cacheFilePath); + return JsonSerializer.Deserialize>(cachedData); + } + + return null; + } + + private async Task TryGetCachedLatestRelease() + { + if (!File.Exists(_cacheLatestReleaseFilePath)) return null; + + var fileInfo = new FileInfo(_cacheLatestReleaseFilePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath); + return JsonSerializer.Deserialize(cachedData); + } + + return null; + } + + private async Task CacheReleasesAsync(IList updates) + { + try + { + var json = JsonSerializer.Serialize(updates, JsonOptions); + await File.WriteAllTextAsync(_cacheFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache releases"); + } + } + + private async Task CacheLatestReleaseAsync(UpdateNotificationDto update) + { + try + { + var json = JsonSerializer.Serialize(update, JsonOptions); + await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache latest release"); + } + } + + + private static bool IsVersionEqualToBuildVersion(Version updateVersion) { - return updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 && - CompareWithoutRevision(BuildInfo.Version, updateVersion); + return updateVersion == BuildInfo.Version || (updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 && + BuildInfo.Version.CompareWithoutRevision(updateVersion)); } - private static bool CompareWithoutRevision(Version v1, Version v2) - { - if (v1.Major != v2.Major) - return v1.Major == v2.Major; - if (v1.Minor != v2.Minor) - return v1.Minor == v2.Minor; - if (v1.Build != v2.Build) - return v1.Build == v2.Build; - return true; - } - public async Task GetNumberOfReleasesBehind() + /// + /// Returns the number of releases ahead of this install version. If this install version is on a nightly, + /// then include nightly releases, otherwise only count Stable releases. + /// + /// Only count Stable releases + /// + public async Task GetNumberOfReleasesBehind(bool stableOnly = false) { var updates = await GetAllReleases(); - return updates.TakeWhile(update => update.UpdateVersion != update.CurrentVersion).Count(); + + // If the user is on nightly, then we need to handle releases behind differently + if (!stableOnly && (updates[0].IsPrerelease || updates[0].IsOnNightlyInRelease)) + { + return updates.Count(u => u.IsReleaseNewer); + } + + return updates + .Where(update => !update.IsPrerelease) + .Count(u => u.IsReleaseNewer); + } + + /// + /// Clears the Github cache + /// + public void BustGithubCache() + { + try + { + File.Delete(_cacheFilePath); + File.Delete(_cacheLatestReleaseFilePath); + } catch (Exception ex) + { + _logger.LogError(ex, "Failed to clear Github cache"); + } } private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update) @@ -135,18 +468,33 @@ public class VersionUpdaterService : IVersionUpdaterService var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty)); var currentVersion = BuildInfo.Version.ToString(4); + var bodyHtml = _markdown.Transform(update.Body.Trim()); + var parsedSections = ParseReleaseBody(update.Body); + var blogPart = _markdown.Transform(ExtractBlogPart(update.Body).Trim()); return new UpdateNotificationDto() { CurrentVersion = currentVersion, UpdateVersion = updateVersion.ToString(), - UpdateBody = _markdown.Transform(update.Body.Trim()), + UpdateBody = bodyHtml, UpdateTitle = update.Name, UpdateUrl = update.Html_Url, IsDocker = OsInfo.IsDocker, PublishDate = update.Published_At, IsReleaseEqual = IsVersionEqualToBuildVersion(updateVersion), IsReleaseNewer = BuildInfo.Version < updateVersion, + IsPrerelease = false, + + Added = parsedSections.TryGetValue("Added", out var added) ? added : [], + Removed = parsedSections.TryGetValue("Removed", out var removed) ? removed : [], + Changed = parsedSections.TryGetValue("Changed", out var changed) ? changed : [], + Fixed = parsedSections.TryGetValue("Fixed", out var fixes) ? fixes : [], + Theme = parsedSections.TryGetValue("Theme", out var theme) ? theme : [], + Developer = parsedSections.TryGetValue("Developer", out var developer) ? developer : [], + KnownIssues = parsedSections.TryGetValue("Known Issues", out var knownIssues) ? knownIssues : [], + Api = parsedSections.TryGetValue("Api", out var api) ? api : [], + FeatureRequests = parsedSections.TryGetValue("Feature Requests", out var frs) ? frs : [], + BlogPart = blogPart }; } @@ -165,6 +513,26 @@ public class VersionUpdaterService : IVersionUpdaterService } } + private async Task GetVersionFromCommit(string commitSha) + { + try + { + // Use the raw GitHub URL format for the csproj file + var content = await $"https://raw.githubusercontent.com/Kareadita/Kavita/{commitSha}/Kavita.Common/Kavita.Common.csproj" + .WithHeader("User-Agent", "Kavita") + .GetStringAsync(); + + var versionMatch = Regex.Match(content, @"([0-9\.]+)"); + return versionMatch.Success ? versionMatch.Groups[1].Value : null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get version from commit {Sha}: {Message}", commitSha, ex.Message); + return null; + } + } + + private static async Task GetGithubRelease() { @@ -176,13 +544,109 @@ public class VersionUpdaterService : IVersionUpdaterService return update; } - private static async Task> GetGithubReleases() + private static async Task> GetGithubReleases() { var update = await GithubAllReleasesUrl .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") - .GetJsonAsync>(); + .GetJsonAsync>(); return update; } + + private static string ExtractBlogPart(string body) + { + if (body.StartsWith('#')) return string.Empty; + var match = BlogPartRegex().Match(body); + return match.Success ? match.Groups[1].Value.Trim() : body.Trim(); + } + + private static Dictionary> ParseReleaseBody(string body) + { + var sections = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var lines = body.Split('\n'); + string? currentSection = null; + + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + + // Check for section headers (case-insensitive) + if (trimmedLine.StartsWith('#')) + { + currentSection = trimmedLine.TrimStart('#').Trim(); + sections[currentSection] = []; + continue; + } + + // Parse items under a section + if (currentSection != null && + trimmedLine.StartsWith("- ") && + !string.IsNullOrWhiteSpace(trimmedLine)) + { + // Remove "Fixed:", "Added:" etc. if present + var cleanedItem = CleanSectionItem(trimmedLine); + + // Some sections like API/Developer/Removed don't have the title repeated, so we need to check for an additional cleaning + if (cleanedItem.StartsWith("- ")) + { + cleanedItem = trimmedLine.Substring(2); + } + + // Only add non-empty items + if (!string.IsNullOrWhiteSpace(cleanedItem)) + { + sections[currentSection].Add(cleanedItem); + } + } + } + + return sections; + } + + private static string CleanSectionItem(string item) + { + // Remove everything up to and including the first ":" + var colonIndex = item.IndexOf(':'); + if (colonIndex != -1) + { + item = item.Substring(colonIndex + 1).Trim(); + } + + return item; + } + + private sealed class PullRequestInfo + { + public required string Title { get; init; } + public required string Body { get; init; } + public required string Html_Url { get; init; } + public required string Merged_At { get; init; } + public required int Number { get; init; } + } + + private sealed class CommitInfo + { + public required string Sha { get; init; } + public required CommitDetail Commit { get; init; } + public required string Html_Url { get; init; } + } + + private sealed class CommitDetail + { + public required string Message { get; init; } + public required CommitAuthor Author { get; init; } + } + + private sealed class CommitAuthor + { + public required string Date { get; init; } + } + + private sealed class NightlyInfo + { + public required string Version { get; init; } + public required int PrNumber { get; init; } + public required DateTime Date { get; init; } + } } diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index cc998de3d..720d97663 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -4,10 +4,12 @@ using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Text; +using System.Threading; using System.Threading.Tasks; using API.Data; using API.DTOs.Account; using API.Entities; +using API.Helpers; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -35,6 +37,7 @@ public class TokenService : ITokenService private readonly IUnitOfWork _unitOfWork; private readonly SymmetricSecurityKey _key; private const string RefreshTokenName = "RefreshToken"; + private static readonly SemaphoreSlim _refreshTokenLock = new SemaphoreSlim(1, 1); public TokenService(IConfiguration config, UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) { @@ -80,6 +83,8 @@ public class TokenService : ITokenService public async Task ValidateRefreshToken(TokenRequestDto request) { + await _refreshTokenLock.WaitAsync(); + try { var tokenHandler = new JwtSecurityTokenHandler(); @@ -90,6 +95,7 @@ public class TokenService : ITokenService _logger.LogDebug("[RefreshToken] failed to validate due to not finding user in RefreshToken"); return null; } + var user = await _userManager.FindByNameAsync(username); if (user == null) { @@ -97,13 +103,19 @@ public class TokenService : ITokenService return null; } - var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken); + var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, + RefreshTokenName, request.RefreshToken); if (!validated && tokenContent.ValidTo <= DateTime.UtcNow.Add(TimeSpan.FromHours(1))) { _logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token"); return null; } + // Remove the old refresh token first + await _userManager.RemoveAuthenticationTokenAsync(user, + TokenOptions.DefaultProvider, + RefreshTokenName); + try { user.UpdateLastActive(); @@ -120,7 +132,8 @@ public class TokenService : ITokenService Token = await CreateToken(user), RefreshToken = await CreateRefreshToken(user) }; - } catch (SecurityTokenExpiredException ex) + } + catch (SecurityTokenExpiredException ex) { // Handle expired token _logger.LogError(ex, "Failed to validate refresh token"); @@ -132,6 +145,10 @@ public class TokenService : ITokenService _logger.LogError(ex, "Failed to validate refresh token"); return null; } + finally + { + _refreshTokenLock.Release(); + } } public async Task GetJwtFromUser(AppUser user) @@ -143,12 +160,12 @@ public class TokenService : ITokenService public static bool HasTokenExpired(string? token) { - if (string.IsNullOrEmpty(token)) return true; + return !JwtHelper.IsTokenValid(token); + } - var tokenHandler = new JwtSecurityTokenHandler(); - var tokenContent = tokenHandler.ReadJwtToken(token); - var validToUtc = tokenContent.ValidTo.ToUniversalTime(); - return validToUtc < DateTime.UtcNow; + public static DateTime GetTokenExpiry(string? token) + { + return JwtHelper.GetTokenExpiry(token); } } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index de9818b79..87a464e6a 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -1,5 +1,6 @@ using System; using API.DTOs.Update; +using API.Entities.Person; using API.Extensions; using API.Services.Plus; @@ -147,6 +148,14 @@ public static class MessageFactory /// Volume is removed from server /// public const string VolumeRemoved = "VolumeRemoved"; + /// + /// A Person merged has been merged into another + /// + public const string PersonMerged = "PersonMerged"; + /// + /// A Rate limit error was hit when matching a series with Kavita+ + /// + public const string ExternalMatchRateLimitError = "ExternalMatchRateLimitError"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -661,4 +670,29 @@ public static class MessageFactory EventType = ProgressEventType.Single, }; } + + public static SignalRMessage PersonMergedMessage(Person dst, Person src) + { + return new SignalRMessage() + { + Name = PersonMerged, + Body = new + { + srcId = src.Id, + dstName = dst.Name, + }, + }; + } + public static SignalRMessage ExternalMatchRateLimitErrorEvent(int seriesId, string seriesName) + { + return new SignalRMessage() + { + Name = ExternalMatchRateLimitError, + Body = new + { + seriesId = seriesId, + seriesName = seriesName, + }, + }; + } } diff --git a/API/Startup.cs b/API/Startup.cs index 8567ab841..f57cb7d01 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -41,6 +41,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; using Microsoft.OpenApi.Models; using Serilog; +using Swashbuckle.AspNetCore.SwaggerGen; using TaskScheduler = API.Services.TaskScheduler; namespace API; @@ -54,6 +55,9 @@ public class Startup { _config = config; _env = env; + + // Disable Hangfire Automatic Retry + GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute { Attempts = 0 }); } // This method gets called by the runtime. Use this method to add services to the container. @@ -137,9 +141,9 @@ public class Startup { c.SwaggerDoc("v1", new OpenApiInfo { - Version = "3.1.0", - Title = "Kavita", - Description = $"Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v{BuildInfo.Version.ToString()}", + Version = BuildInfo.Version.ToString(), + Title = $"Kavita", + Description = $"Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v{BuildInfo.Version}", License = new OpenApiLicense { Name = "GPL-3.0", @@ -222,7 +226,7 @@ public class Startup // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env, IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService, - IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService) + IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService, IVersionUpdaterService versionService) { var logger = serviceProvider.GetRequiredService>(); @@ -234,9 +238,10 @@ public class Startup // Apply all migrations on startup var dataContext = serviceProvider.GetRequiredService(); - logger.LogInformation("Running Migrations"); + #region Migrations + // v0.7.9 await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger); @@ -274,14 +279,40 @@ public class Startup // v0.8.4 await MigrateLowestSeriesFolderPath2.Migrate(dataContext, unitOfWork, logger); await ManualMigrateRemovePeople.Migrate(dataContext, logger); + await MigrateDuplicateDarkTheme.Migrate(dataContext, logger); + await ManualMigrateUnscrobbleBookLibraries.Migrate(dataContext, logger); + + // v0.8.5 + await ManualMigrateBlacklistTableToSeries.Migrate(dataContext, logger); + await ManualMigrateInvalidBlacklistSeries.Migrate(dataContext, logger); + await ManualMigrateScrobbleErrors.Migrate(dataContext, logger); + await ManualMigrateNeedsManualMatch.Migrate(dataContext, logger); + await MigrateProgressExportForV085.Migrate(dataContext, directoryService, logger); + + // v0.8.6 + await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger); + await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger); + + // v0.8.7 + await ManualMigrateReadingProfiles.Migrate(dataContext, logger); + + #endregion // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); + var isVersionDifferent = installVersion.Value != BuildInfo.Version.ToString(); installVersion.Value = BuildInfo.Version.ToString(); unitOfWork.SettingsRepository.Update(installVersion); await unitOfWork.CommitAsync(); logger.LogInformation("Running Migrations - complete"); + + if (isVersionDifferent) + { + // Clear the Github cache so update stuff shows correctly + versionService.BustGithubCache(); + } + }).GetAwaiter() .GetResult(); } diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index 83a13e7ce..ad2d89fa5 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -1,8 +1,8 @@ { "TokenKey": "super secret unguessable key that is longer because we require it", "Port": 5000, - "IpAddresses": "0.0.0.0,::", - "BaseUrl": "/test/", + "IpAddresses": "", + "BaseUrl": "/", "Cache": 75, "AllowIFraming": false } \ No newline at end of file diff --git a/API/config/appsettings.json b/API/config/appsettings.json index 3eeee1c18..c77ff6a30 100644 --- a/API/config/appsettings.json +++ b/API/config/appsettings.json @@ -3,5 +3,5 @@ "Port": 5000, "IpAddresses": "", "BaseUrl": "/", - "Cache": 50 + "Cache": 75 } diff --git a/API/config/templates/EmailPasswordReset.html b/API/config/templates/EmailPasswordReset.html index 2486d3a60..7ac7dc315 100644 --- a/API/config/templates/EmailPasswordReset.html +++ b/API/config/templates/EmailPasswordReset.html @@ -199,7 +199,7 @@
-
Email confirmation is required for continued access. Click the button to confirm your email.
+
{{Preheader}}
+ + + + + + + Kavita - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
{{Preheader}}
+ + + +
+ + + diff --git a/API/config/templates/TokenExpiringSoon.html b/API/config/templates/TokenExpiringSoon.html new file mode 100644 index 000000000..eac990260 --- /dev/null +++ b/API/config/templates/TokenExpiringSoon.html @@ -0,0 +1,344 @@ + + + + + + + + + + + + + Kavita - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
{{Preheader}}
+ + + +
+ + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43cd2e208..292217862 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavit - HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc) - [Git](https://git-scm.com/downloads) - [NodeJS](https://nodejs.org/en/download/) (Node 18.13.X or higher) -- .NET 8.0+ +- .NET 9.0+ - dotnet tool install -g Swashbuckle.AspNetCore.Cli ### Getting started ### diff --git a/Dockerfile b/Dockerfile index 6d52acaba..bfc253c0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN mkdir /files COPY _output/*.tar.gz /files/ COPY UI/Web/dist/browser /files/wwwroot COPY copy_runtime.sh /copy_runtime.sh + +RUN chmod +x /copy_runtime.sh RUN /copy_runtime.sh RUN chmod +x /Kavita/Kavita @@ -34,6 +36,7 @@ WORKDIR /kavita HEALTHCHECK --interval=30s --timeout=15s --start-period=30s --retries=3 CMD curl -fsS http://localhost:5000/api/health || exit 1 +# Enable detection of running in a container ENV DOTNET_RUNNING_IN_CONTAINER=true ENTRYPOINT [ "/bin/bash" ] diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index ca0fc40ec..ba4fd09b7 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -16,7 +16,9 @@ public static class Configuration public const long DefaultCacheMemory = 75; private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); - public static string KavitaPlusApiUrl = "https://plus.kavitareader.com"; + public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development + ? "https://plus.kavitareader.com" : "https://plus.kavitareader.com"; // http://localhost:5020 + public static readonly string StatsApiUrl = "https://stats.kavitareader.com"; public static int Port { @@ -314,6 +316,7 @@ public static class Configuration { public string TokenKey { get; set; } // ReSharper disable once MemberHidesStaticFromOuterClass +#pragma warning disable S3218 public int Port { get; set; } = DefaultHttpPort; // ReSharper disable once MemberHidesStaticFromOuterClass public string IpAddresses { get; set; } = string.Empty; @@ -322,6 +325,7 @@ public static class Configuration // ReSharper disable once MemberHidesStaticFromOuterClass public long Cache { get; set; } = DefaultCacheMemory; // ReSharper disable once MemberHidesStaticFromOuterClass - public bool AllowIFraming { get; set; } = false; + public bool AllowIFraming { get; init; } = false; +#pragma warning restore S3218 } } diff --git a/Kavita.Common/Helpers/CronHelper.cs b/Kavita.Common/Helpers/CronHelper.cs index 77a4e934e..0b40113ce 100644 --- a/Kavita.Common/Helpers/CronHelper.cs +++ b/Kavita.Common/Helpers/CronHelper.cs @@ -13,7 +13,7 @@ public static class CronHelper CronExpression.Parse(cronExpression); return true; } - catch (Exception ex) + catch (Exception) { /* Swallow */ return false; diff --git a/Kavita.Common/Helpers/FlurlConfiguration.cs b/Kavita.Common/Helpers/FlurlConfiguration.cs new file mode 100644 index 000000000..b80dff8d9 --- /dev/null +++ b/Kavita.Common/Helpers/FlurlConfiguration.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Flurl.Http; + +namespace Kavita.Common.Helpers; + +/// +/// Helper class for configuring Flurl client for a specific URL. +/// +public static class FlurlConfiguration +{ + private static readonly List ConfiguredClients = new List(); + private static readonly Lock Lock = new Lock(); + + /// + /// Configures the Flurl client for the specified URL. + /// + /// The URL to configure the client for. + public static void ConfigureClientForUrl(string url) + { + //Important client are mapped without path, per example two urls pointing to the same host:port but different path, will use the same client. + lock (Lock) + { + var ur = new Uri(url); + //key is host:port + var host = ur.Host + ":" + ur.Port; + if (ConfiguredClients.Contains(host)) return; + + FlurlHttp.ConfigureClientForUrl(url).ConfigureInnerHandler(cli => +#pragma warning disable S4830 + cli.ServerCertificateCustomValidationCallback = (_, _, _, _) => true); +#pragma warning restore S4830 + + ConfiguredClients.Add(host); + } + } +} diff --git a/Kavita.Common/Helpers/UntrustedCertClientFactory.cs b/Kavita.Common/Helpers/UntrustedCertClientFactory.cs deleted file mode 100644 index 6ddb2a9f3..000000000 --- a/Kavita.Common/Helpers/UntrustedCertClientFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Net.Http; -using Flurl.Http.Configuration; - -namespace Kavita.Common.Helpers; - -public class UntrustedCertClientFactory : DefaultHttpClientFactory -{ - public override HttpMessageHandler CreateMessageHandler() { - return new HttpClientHandler { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }; - } -} diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 769e228f9..c7dd0ab94 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -1,23 +1,23 @@ - net8.0 + net9.0 kavitareader.com Kavita - 0.8.3.16 + 0.8.7.1 en true - + - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/Kavita.sln.DotSettings b/Kavita.sln.DotSettings index 92adaa72f..b46c328cd 100644 --- a/Kavita.sln.DotSettings +++ b/Kavita.sln.DotSettings @@ -2,9 +2,13 @@ ExplicitlyExcluded True True + True + True + True True True True + True True True True diff --git a/README.md b/README.md index 0c083b900..ffff8d831 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ your reading collection with your friends and family! - Rich web readers supporting webtoon, continuous reading mode (continue without leaving the reader), virtual pages (epub), etc - Ability to customize your dashboard and side nav with smart filters, custom order and visibility toggles - Full Localization Support +- Ability to download metadata (available via [Kavita+](https://wiki.kavitareader.com/kavita+)) ## Support @@ -40,7 +41,7 @@ your reading collection with your friends and family! ## Demo If you want to try out Kavita, a demo is available: -[https://demo.kavitareader.com/](https://demo.kavitareader.com/) +[https://demo.kavitareader.com/](https://demo.kavitareader.com/login?apiKey=9003cf99-9213-4206-a787-af2fe4cc5f1f) ``` Username: demouser Password: Demouser64 @@ -49,7 +50,7 @@ Password: Demouser64 ## Setup The easiest way to get started is to visit our Wiki which has up-to-date information on a variety of install methods and platforms. -[https://wiki.kavitareader.com/installation/getting-started](https://wiki.kavitareader.com/installation/getting-started) +[https://wiki.kavitareader.com/getting-started](https://wiki.kavitareader.com/getting-started) ## Feature Requests Got a great idea? Throw it up on [Discussions](https://github.com/Kareadita/Kavita/discussions/2529) or vote on another idea. Many great features in Kavita are driven by our community. @@ -106,13 +107,10 @@ Support this project by becoming a sponsor. Your logo will show up here with a l ## Mega Sponsors -## JetBrains -Thank you to [ JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. - -* [ Rider](http://www.jetbrains.com/rider/) +## Powered By +[![JetBrains logo.](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)](https://jb.gg/OpenSource) ### License - * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) * Copyright 2020-2024 diff --git a/TestData b/TestData deleted file mode 160000 index 4f5750025..000000000 --- a/TestData +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4f5750025a1c0b48cd72eaa6f1b61642c41f147f diff --git a/UI/Web/.editorconfig b/UI/Web/.editorconfig index 2c6908b84..28045b9af 100644 --- a/UI/Web/.editorconfig +++ b/UI/Web/.editorconfig @@ -8,6 +8,12 @@ indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true +[*.json] +indent_size = 2 + +[en.json] +indent_size = 4 + [*.html] indent_size = 2 diff --git a/UI/Web/.gitignore b/UI/Web/.gitignore index 73b400165..8132126c9 100644 --- a/UI/Web/.gitignore +++ b/UI/Web/.gitignore @@ -2,3 +2,4 @@ node_modules/ test-results/ playwright-report/ i18n-cache-busting.json +e2e-tests/environments/environment.local.ts diff --git a/UI/Web/angular.json b/UI/Web/angular.json index 60fc909a9..1ce56fa2e 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -24,13 +24,14 @@ "prefix": "app", "architect": { "build": { - "builder": "@angular-devkit/build-angular:application", + "builder": "@angular/build:application", "options": { "outputPath": "dist", "index": "src/index.html", "browser": "src/main.ts", "polyfills": [ - "zone.js" + "@angular/localize/init", + "zone.js" ], "inlineStyleLanguage": "scss", "tsConfig": "tsconfig.app.json", @@ -55,7 +56,12 @@ }, "extractLicenses": false, "optimization": false, - "namedChunks": true + "namedChunks": true, + "stylePreprocessorOptions": { + "sass": { + "silenceDeprecations": ["mixed-decls", "color-functions", "global-builtin", "import"] + } + } }, "configurations": { "production": { @@ -87,7 +93,7 @@ "defaultConfiguration": "" }, "serve": { - "builder": "@angular-devkit/build-angular:dev-server", + "builder": "@angular/build:dev-server", "options": { "sslKey": "./ssl/server.key", "sslCert": "./ssl/server.crt", @@ -101,7 +107,7 @@ } }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", + "builder": "@angular/build:extract-i18n", "options": { "buildTarget": "kavita-webui:build" } diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 5c4a5c95b..cfce8cded 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -8,70 +8,70 @@ "name": "kavita-webui", "version": "0.7.12.1", "dependencies": { - "@angular/animations": "^17.3.4", - "@angular/cdk": "^17.3.4", - "@angular/common": "^17.3.4", - "@angular/compiler": "^17.3.4", - "@angular/core": "^17.3.4", - "@angular/forms": "^17.3.4", - "@angular/localize": "^17.3.4", - "@angular/platform-browser": "^17.3.4", - "@angular/platform-browser-dynamic": "^17.3.4", - "@angular/router": "^17.3.4", - "@fortawesome/fontawesome-free": "^6.5.2", - "@iharbeck/ngx-virtual-scroller": "^17.0.2", - "@iplab/ngx-file-upload": "^17.1.0", - "@jsverse/transloco": "^7.4.3", + "@angular-slider/ngx-slider": "^19.0.0", + "@angular/animations": "^19.2.5", + "@angular/cdk": "^19.2.8", + "@angular/common": "^19.2.5", + "@angular/compiler": "^19.2.5", + "@angular/core": "^19.2.5", + "@angular/forms": "^19.2.5", + "@angular/localize": "^19.2.5", + "@angular/platform-browser": "^19.2.5", + "@angular/platform-browser-dynamic": "^19.2.5", + "@angular/router": "^19.2.5", + "@fortawesome/fontawesome-free": "^6.7.2", + "@iharbeck/ngx-virtual-scroller": "^19.0.1", + "@iplab/ngx-file-upload": "^19.0.3", + "@jsverse/transloco": "^7.6.1", "@jsverse/transloco-locale": "^7.0.1", - "@jsverse/transloco-persist-lang": "^7.0.1", + "@jsverse/transloco-persist-lang": "^7.0.2", "@jsverse/transloco-persist-translations": "^7.0.1", "@jsverse/transloco-preload-langs": "^7.0.1", - "@microsoft/signalr": "^7.0.14", - "@ng-bootstrap/ng-bootstrap": "^16.0.0", + "@microsoft/signalr": "^8.0.7", + "@ng-bootstrap/ng-bootstrap": "^18.0.0", "@popperjs/core": "^2.11.7", - "@swimlane/ngx-charts": "^20.5.0", - "@tweenjs/tween.js": "^23.1.1", + "@siemens/ngx-datatable": "^22.4.1", + "@swimlane/ngx-charts": "^22.0.0-alpha.0", + "@tweenjs/tween.js": "^25.0.0", "bootstrap": "^5.3.2", "charts.css": "^1.1.0", "file-saver": "^2.0.5", - "luxon": "^3.4.4", + "luxon": "^3.6.1", "ng-circle-progress": "^1.7.1", "ng-lazyload-image": "^9.1.3", - "ng-select2-component": "^14.0.1", - "ngx-color-picker": "^16.0.0", - "ngx-extended-pdf-viewer": "^18.1.9", + "ng-select2-component": "^17.2.4", + "ngx-color-picker": "^19.0.0", + "ngx-extended-pdf-viewer": "^23.0.0-alpha.7", "ngx-file-drop": "^16.0.0", - "ngx-slider-v2": "^17.0.0", "ngx-stars": "^1.6.5", - "ngx-toaster": "^1.0.1", - "ngx-toastr": "^18.0.0", + "ngx-toastr": "^19.0.0", "nosleep.js": "^0.12.0", - "rxjs": "^7.8.0", + "rxjs": "^7.8.2", "screenfull": "^6.0.2", "swiper": "^8.4.6", - "tslib": "^2.6.2", - "zone.js": "^0.14.3" + "tslib": "^2.8.1", + "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "^17.3.4", - "@angular-eslint/builder": "^17.3.0", - "@angular-eslint/eslint-plugin": "^17.3.0", - "@angular-eslint/eslint-plugin-template": "^17.3.0", - "@angular-eslint/schematics": "^17.3.0", - "@angular-eslint/template-parser": "^17.3.0", - "@angular/cli": "^17.3.4", - "@angular/compiler-cli": "^17.3.4", + "@angular-eslint/builder": "^19.3.0", + "@angular-eslint/eslint-plugin": "^19.3.0", + "@angular-eslint/eslint-plugin-template": "^19.3.0", + "@angular-eslint/schematics": "^19.3.0", + "@angular-eslint/template-parser": "^19.3.0", + "@angular/build": "^19.2.6", + "@angular/cli": "^19.2.6", + "@angular/compiler-cli": "^19.2.5", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", - "@types/luxon": "^3.4.0", - "@types/node": "^20.10.0", - "@typescript-eslint/eslint-plugin": "^7.2.0", - "@typescript-eslint/parser": "^7.2.0", - "eslint": "^8.57.0", + "@types/luxon": "^3.6.2", + "@types/node": "^22.13.13", + "@typescript-eslint/eslint-plugin": "^8.28.0", + "@typescript-eslint/parser": "^8.28.0", + "eslint": "^9.23.0", "jsonminify": "^0.4.2", "karma-coverage": "~2.2.0", "ts-node": "~10.9.1", - "typescript": "^5.2.2", + "typescript": "^5.5.4", "webpack-bundle-analyzer": "^4.10.2" } }, @@ -97,112 +97,281 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1703.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.4.tgz", - "integrity": "sha512-o+XCMOiMh8tmQGEwcxjAj2/lmUVT7CGSUAM31ydDomVOFFw4CnBvsoyKqQNRC+/AUXvovb2dCegQl/lTAnrwOg==", + "version": "0.1902.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.6.tgz", + "integrity": "sha512-Dx6yPxpaE5AhP6UtrVRDCc9Ihq9B65LAbmIh3dNOyeehratuaQS0TYNKjbpaevevJojW840DTg80N+CrlfYp9g==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.3.4", + "@angular-devkit/core": "19.2.6", "rxjs": "7.8.1" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, - "node_modules/@angular-devkit/build-angular": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.4.tgz", - "integrity": "sha512-8KieoPrsJcFPoza0gLQ6yebtIb3WdH3j/V1TnAihk4tVpgtdch8tOBE3FP1TnSW3RF+iCsA0I5NO9/4YbEsWtw==", + "node_modules/@angular-devkit/architect/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.6.tgz", + "integrity": "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ==", + "dev": true, + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.2", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.6.tgz", + "integrity": "sha512-YTAxNnT++5eflx19OUHmOWu597/TbTel+QARiZCv1xQw99+X8DCKKOUXtqBRd53CAHlREDI33Rn/JLY3NYgMLQ==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "19.2.6", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-eslint/builder": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-19.3.0.tgz", + "integrity": "sha512-j9xNrzZJq29ONSG6EaeQHve0Squkm6u6Dm8fZgWP7crTFOrtLXn7Wxgxuyl9eddpbWY1Ov1gjFuwBVnxIdyAqg==", + "dev": true, + "dependencies": { + "@angular-devkit/architect": ">= 0.1900.0 < 0.2000.0", + "@angular-devkit/core": ">= 19.0.0 < 20.0.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.3.0.tgz", + "integrity": "sha512-63Zci4pvnUR1iSkikFlNbShF1tO5HOarYd8fvNfmOZwFfZ/1T3j3bCy9YbE+aM5SYrWqPaPP/OcwZ3wJ8WNvqA==", + "dev": true + }, + "node_modules/@angular-eslint/eslint-plugin": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.3.0.tgz", + "integrity": "sha512-nBLslLI20KnVbqlfNW7GcnI9R6cYCvRGjOE2QYhzxM316ciAQ62tvQuXP9ZVnRBLSKDAVnMeC0eTq9O4ysrxrQ==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.3.0", + "@angular-eslint/utils": "19.3.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.3.0.tgz", + "integrity": "sha512-WyouppTpOYut+wvv13wlqqZ8EHoDrCZxNfGKuEUYK1BPmQlTB8EIZfQH4iR1rFVS28Rw+XRIiXo1x3oC0SOfnA==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.3.0", + "@angular-eslint/utils": "19.3.0", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" + }, + "peerDependencies": { + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/schematics": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-19.3.0.tgz", + "integrity": "sha512-Wl5sFQ4t84LUb8mJ2iVfhYFhtF55IugXu7rRhPHtgIu9Ty5s1v3HGUx4LKv51m2kWhPPeFOTmjeBv1APzFlmnQ==", + "dev": true, + "dependencies": { + "@angular-devkit/core": ">= 19.0.0 < 20.0.0", + "@angular-devkit/schematics": ">= 19.0.0 < 20.0.0", + "@angular-eslint/eslint-plugin": "19.3.0", + "@angular-eslint/eslint-plugin-template": "19.3.0", + "ignore": "7.0.3", + "semver": "7.7.1", + "strip-json-comments": "3.1.1" + } + }, + "node_modules/@angular-eslint/schematics/node_modules/ignore": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", + "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@angular-eslint/template-parser": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-19.3.0.tgz", + "integrity": "sha512-VxMNgsHXMWbbmZeBuBX5i8pzsSSEaoACVpaE+j8Muk60Am4Mxc0PytJm4n3znBSvI3B7Kq2+vStSRYPkOER4lA==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.3.0", + "eslint-scope": "^8.0.2" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/utils": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-19.3.0.tgz", + "integrity": "sha512-ovvbQh96FIJfepHqLCMdKFkPXr3EbcvYc9kMj9hZyIxs/9/VxwPH7x25mMs4VsL6rXVgH2FgG5kR38UZlcTNNw==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.3.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-slider/ngx-slider": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@angular-slider/ngx-slider/-/ngx-slider-19.0.0.tgz", + "integrity": "sha512-VVJ+Fij5SKnbltxh6TdoBAUAKWfCnSLRPZ7e+r2uO88t8qte5/KHqVOdK4DWCjBr3rEr4YrPR4ylqBCuAWPsKQ==", + "dependencies": { + "detect-passive-events": "^2.0.3", + "rxjs": "^7.8.1", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0" + } + }, + "node_modules/@angular/animations": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.5.tgz", + "integrity": "sha512-m4RtY3z1JuHFCh6OrOHxo25oKEigBDdR/XmdCfXIwfTiObZzNA7VQhysgdrb9IISO99kXbjZUYKDtLzgWT8Klg==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "19.2.5", + "@angular/core": "19.2.5" + } + }, + "node_modules/@angular/build": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.6.tgz", + "integrity": "sha512-+VBLb4ZPLswwJmgfsTFzGex+Sq/WveNc+uaIWyHYjwnuI17NXe1qAAg1rlp72CqGn0cirisfOyAUwPc/xZAgTg==", "dev": true, "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1703.4", - "@angular-devkit/build-webpack": "0.1703.4", - "@angular-devkit/core": "17.3.4", - "@babel/core": "7.24.0", - "@babel/generator": "7.23.6", - "@babel/helper-annotate-as-pure": "7.22.5", - "@babel/helper-split-export-declaration": "7.22.6", - "@babel/plugin-transform-async-generator-functions": "7.23.9", - "@babel/plugin-transform-async-to-generator": "7.23.3", - "@babel/plugin-transform-runtime": "7.24.0", - "@babel/preset-env": "7.24.0", - "@babel/runtime": "7.24.0", - "@discoveryjs/json-ext": "0.5.7", - "@ngtools/webpack": "17.3.4", - "@vitejs/plugin-basic-ssl": "1.1.0", - "ansi-colors": "4.1.3", - "autoprefixer": "10.4.18", - "babel-loader": "9.1.3", - "babel-plugin-istanbul": "6.1.1", - "browserslist": "^4.21.5", - "copy-webpack-plugin": "11.0.0", - "critters": "0.0.22", - "css-loader": "6.10.0", - "esbuild-wasm": "0.20.1", - "fast-glob": "3.3.2", - "http-proxy-middleware": "2.0.6", - "https-proxy-agent": "7.0.4", - "inquirer": "9.2.15", - "jsonc-parser": "3.2.1", - "karma-source-map-support": "1.4.0", - "less": "4.2.0", - "less-loader": "11.1.0", - "license-webpack-plugin": "4.0.2", - "loader-utils": "3.2.1", - "magic-string": "0.30.8", - "mini-css-extract-plugin": "2.8.1", - "mrmime": "2.0.0", - "open": "8.4.2", - "ora": "5.4.1", + "@angular-devkit/architect": "0.1902.6", + "@babel/core": "7.26.10", + "@babel/helper-annotate-as-pure": "7.25.9", + "@babel/helper-split-export-declaration": "7.24.7", + "@babel/plugin-syntax-import-attributes": "7.26.0", + "@inquirer/confirm": "5.1.6", + "@vitejs/plugin-basic-ssl": "1.2.0", + "beasties": "0.2.0", + "browserslist": "^4.23.0", + "esbuild": "0.25.1", + "fast-glob": "3.3.3", + "https-proxy-agent": "7.0.6", + "istanbul-lib-instrument": "6.0.3", + "listr2": "8.2.5", + "magic-string": "0.30.17", + "mrmime": "2.0.1", "parse5-html-rewriting-stream": "7.0.0", - "picomatch": "4.0.1", - "piscina": "4.4.0", - "postcss": "8.4.35", - "postcss-loader": "8.1.1", - "resolve-url-loader": "5.0.0", - "rxjs": "7.8.1", - "sass": "1.71.1", - "sass-loader": "14.1.1", - "semver": "7.6.0", - "source-map-loader": "5.0.0", + "picomatch": "4.0.2", + "piscina": "4.8.0", + "rollup": "4.34.8", + "sass": "1.85.0", + "semver": "7.7.1", "source-map-support": "0.5.21", - "terser": "5.29.1", - "tree-kill": "1.2.2", - "tslib": "2.6.2", - "undici": "6.11.1", - "vite": "5.1.7", - "watchpack": "2.4.0", - "webpack": "5.90.3", - "webpack-dev-middleware": "6.1.2", - "webpack-dev-server": "4.15.1", - "webpack-merge": "5.10.0", - "webpack-subresource-integrity": "5.1.0" + "vite": "6.2.4", + "watchpack": "2.4.2" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, "optionalDependencies": { - "esbuild": "0.20.1" + "lmdb": "3.2.6" }, "peerDependencies": { - "@angular/compiler-cli": "^17.0.0", - "@angular/localize": "^17.0.0", - "@angular/platform-server": "^17.0.0", - "@angular/service-worker": "^17.0.0", - "@web/test-runner": "^0.18.0", - "browser-sync": "^3.0.2", - "jest": "^29.5.0", - "jest-environment-jsdom": "^29.5.0", - "karma": "^6.3.0", - "ng-packagr": "^17.0.0", - "protractor": "^7.0.0", - "tailwindcss": "^2.0.0 || ^3.0.0", - "typescript": ">=5.2 <5.5" + "@angular/compiler": "^19.0.0 || ^19.2.0-next.0", + "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", + "@angular/localize": "^19.0.0 || ^19.2.0-next.0", + "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", + "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", + "@angular/ssr": "^19.2.6", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^19.0.0 || ^19.2.0-next.0", + "postcss": "^8.4.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "typescript": ">=5.5 <5.9" }, "peerDependenciesMeta": { "@angular/localize": { @@ -214,25 +383,19 @@ "@angular/service-worker": { "optional": true }, - "@web/test-runner": { - "optional": true - }, - "browser-sync": { - "optional": true - }, - "jest": { - "optional": true - }, - "jest-environment-jsdom": { + "@angular/ssr": { "optional": true }, "karma": { "optional": true }, + "less": { + "optional": true + }, "ng-packagr": { "optional": true }, - "protractor": { + "postcss": { "optional": true }, "tailwindcss": { @@ -240,220 +403,102 @@ } } }, - "node_modules/@angular-devkit/build-webpack": { - "version": "0.1703.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.4.tgz", - "integrity": "sha512-9Vsl6rfIH8kF02W7i3tW/aMOT2Ld1zpcok7n7JdL3Pb7oW0SOjt73FN6Ykm/hVig12gsOGJtEsDfQRsnCddmfQ==", + "node_modules/@angular/build/node_modules/@babel/core": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", + "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1703.4", - "rxjs": "7.8.1" + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" + "node": ">=6.9.0" }, - "peerDependencies": { - "webpack": "^5.30.0", - "webpack-dev-server": "^4.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@angular-devkit/core": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.4.tgz", - "integrity": "sha512-vE69/Db555NTRPh+LUFO3rAQBbv7QGrK59F7chRggDZKamtCq/FfhEg2O+0BXQnUitOQN6WgQ79+payFYWyCCg==", + "node_modules/@angular/build/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "dependencies": { - "ajv": "8.12.0", - "ajv-formats": "2.1.1", - "jsonc-parser": "3.2.1", - "picomatch": "4.0.1", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^3.5.2" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@angular-devkit/schematics": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.4.tgz", - "integrity": "sha512-Z6801QhIwrMTcKPzdo9si+ZtJkPz8fys0ftOTfTM66+tDECasU7pvk8Dr54WkDY29mdSHzPxpSxAsooEwfxvQQ==", - "dev": true, - "dependencies": { - "@angular-devkit/core": "17.3.4", - "jsonc-parser": "3.2.1", - "magic-string": "0.30.8", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-eslint/builder": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-17.3.0.tgz", - "integrity": "sha512-JXSZE7+KA3UGU6jwc0v9lwOIMptosrvLIOXGlXqrhHWEXfkfu3ENPq1Lm3K8jLndQ57XueEhC+Nab/AuUiWA/Q==", - "dev": true, - "dependencies": { - "@nx/devkit": "^17.2.8 || ^18.0.0", - "nx": "^17.2.8 || ^18.0.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-17.3.0.tgz", - "integrity": "sha512-ejfNzRuBeHUV8m2fkgs+M809rj5STuCuQo4fdfc6ccQpzXDI6Ha7BKpTznWfg5g529q/wrkoGSGgFxU9Yc2/dQ==", + "node_modules/@angular/build/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/@angular-eslint/eslint-plugin": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-17.3.0.tgz", - "integrity": "sha512-81cQbOEPoQupFX8WmpqZn+y8VA7JdVRGBtt+uJNKBXcJknTpPWdLBZRFlgVakmC24iEZ0Fint/N3NBBQI3mz2A==", + "node_modules/@angular/build/node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, "dependencies": { - "@angular-eslint/utils": "17.3.0", - "@typescript-eslint/utils": "7.2.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-17.3.0.tgz", - "integrity": "sha512-9l/aRfpE9MCRVDWRb+rSB9Zei0paep1vqV6M/87VUnzBnzqeMRnVuPvQowilh2zweVSGKBF25Vp4HkwOL6ExDQ==", - "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "17.3.0", - "@angular-eslint/utils": "17.3.0", - "@typescript-eslint/type-utils": "7.2.0", - "@typescript-eslint/utils": "7.2.0", - "aria-query": "5.3.0", - "axobject-query": "4.0.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/schematics": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-17.3.0.tgz", - "integrity": "sha512-5yssd5EOomxlKt9vN/OXXCTCuI3Pmfj16pkjBDoW0wzC8/M2l5zlXIEfoKumHYv2wtF553LhaMXVYVU35e0lTw==", - "dev": true, - "dependencies": { - "@angular-eslint/eslint-plugin": "17.3.0", - "@angular-eslint/eslint-plugin-template": "17.3.0", - "@nx/devkit": "^17.2.8 || ^18.0.0", - "ignore": "5.3.1", - "nx": "^17.2.8 || ^18.0.0", - "strip-json-comments": "3.1.1", - "tmp": "0.2.3" - }, - "peerDependencies": { - "@angular/cli": ">= 17.0.0 < 18.0.0" - } - }, - "node_modules/@angular-eslint/template-parser": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-17.3.0.tgz", - "integrity": "sha512-m+UzAnWgtjeS0x6skSmR0eXltD/p7HZA+c8pPyAkiHQzkxE7ohhfyZc03yWGuYJvWQUqQAKKdO/nQop14TP0bg==", - "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "17.3.0", - "eslint-scope": "^8.0.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/utils": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-17.3.0.tgz", - "integrity": "sha512-PJT9pxWqpvI9OXO+7L5SIVhvMW+RFjeafC7PYjtvSbNFpz+kF644BiAcfMJ0YqBnkrw3JXt+RAX25CT4mXIoXw==", - "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "17.3.0", - "@typescript-eslint/utils": "7.2.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular/animations": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-17.3.4.tgz", - "integrity": "sha512-2nBgXRdTSVPZMueV6ZJjajDRucwJBLxwiVhGafk/nI5MJF0Yss/Jfp2Kfzk5Xw2AqGhz0rd00IyNNUQIzO2mlw==", - "dependencies": { - "tslib": "^2.3.0" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, "engines": { - "node": "^18.13.0 || >=20.9.0" - }, - "peerDependencies": { - "@angular/core": "17.3.4" + "node": ">=10" } }, "node_modules/@angular/cdk": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.3.4.tgz", - "integrity": "sha512-/wbKUbc0YC3HGE2TCgW7D07Q99PZ/5uoRvMyWw0/wHa8VLNavXZPecbvtyLs//3HnqoCMSUFE7E2Mrd7jAWfcA==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.8.tgz", + "integrity": "sha512-ZZqWVYFF80TdjWkk2sc9Pn2luhiYeC78VH3Yjeln4wXMsTGDsvKPBcuOxSxxpJ31saaVBehDjBUuXMqGRj8KuA==", "dependencies": { + "parse5": "^7.1.2", "tslib": "^2.3.0" }, - "optionalDependencies": { - "parse5": "^7.1.2" - }, "peerDependencies": { - "@angular/common": "^17.0.0 || ^18.0.0", - "@angular/core": "^17.0.0 || ^18.0.0", + "@angular/common": "^19.0.0 || ^20.0.0", + "@angular/core": "^19.0.0 || ^20.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/cli": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.4.tgz", - "integrity": "sha512-o4oIA2stUwXOur/T/kP3Zr8ZUCB4VYmvjACbsQ3tpzVCFYPeaW9psQagBNJfaBVVDSYL+EacVYBYJR9ZImvcGw==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.6.tgz", + "integrity": "sha512-eZhFOSsDUHKaciwcWdU5C54ViAvPPdZJf42So93G2vZWDtEq6Uk47huocn1FY9cMhDvURfYLNrrLMpUDtUSsSA==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1703.4", - "@angular-devkit/core": "17.3.4", - "@angular-devkit/schematics": "17.3.4", - "@schematics/angular": "17.3.4", + "@angular-devkit/architect": "0.1902.6", + "@angular-devkit/core": "19.2.6", + "@angular-devkit/schematics": "19.2.6", + "@inquirer/prompts": "7.3.2", + "@listr2/prompt-adapter-inquirer": "2.0.18", + "@schematics/angular": "19.2.6", "@yarnpkg/lockfile": "1.1.0", - "ansi-colors": "4.1.3", - "ini": "4.1.2", - "inquirer": "9.2.15", - "jsonc-parser": "3.2.1", - "npm-package-arg": "11.0.1", - "npm-pick-manifest": "9.0.0", - "open": "8.4.2", - "ora": "5.4.1", - "pacote": "17.0.6", - "resolve": "1.22.8", - "semver": "7.6.0", + "ini": "5.0.0", + "jsonc-parser": "3.3.1", + "listr2": "8.2.5", + "npm-package-arg": "12.0.2", + "npm-pick-manifest": "10.0.0", + "pacote": "20.0.0", + "resolve": "1.22.10", + "semver": "7.7.1", "symbol-observable": "4.0.0", "yargs": "17.7.2" }, @@ -461,53 +506,46 @@ "ng": "bin/ng.js" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, "node_modules/@angular/common": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.3.4.tgz", - "integrity": "sha512-rEsmtwUMJaNvaimh9hwaHdDLXaOIrjEnYdhmJUvDaKPQaFfSbH3CGGVz9brUyzVJyiWJYkYM0ssxavczeiEe8g==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.5.tgz", + "integrity": "sha512-vFCBdas4C5PxP6ts/4TlRddWD3DUmI3aaO0QZdZvqyLHy428t84ruYdsJXKaeD8ie2U4/9F3a1tsklclRG/BBA==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "17.3.4", + "@angular/core": "19.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.3.4.tgz", - "integrity": "sha512-YrDClIzgj6nQwiYHrfV6AkT1C5LCDgJh+LICus/2EY1w80j1Qf48Zh4asictReePdVE2Tarq6dnpDh4RW6LenQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.5.tgz", + "integrity": "sha512-34J+HubQjwkbZ0AUtU5sa4Zouws9XtP/fKaysMQecoYJTZ3jewzLSRu3aAEZX1Y4gIrcVVKKIxM6oWoXKwYMOA==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" - }, - "peerDependencies": { - "@angular/core": "17.3.4" - }, - "peerDependenciesMeta": { - "@angular/core": { - "optional": true - } + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" } }, "node_modules/@angular/compiler-cli": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz", - "integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz", + "integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==", + "dev": true, "dependencies": { - "@babel/core": "7.23.9", + "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^3.0.0", + "chokidar": "^4.0.0", "convert-source-map": "^1.5.1", "reflect-metadata": "^0.2.0", "semver": "^7.0.0", @@ -520,95 +558,81 @@ "ngcc": "bundles/ngcc/index.js" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "17.3.4", - "typescript": ">=5.2 <5.5" + "@angular/compiler": "19.2.5", + "typescript": ">=5.5 <5.9" } }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", - "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", + "node_modules/@angular/compiler-cli/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.9", - "@babel/parser": "^7.23.9", - "@babel/template": "^7.23.9", - "@babel/traverse": "^7.23.9", - "@babel/types": "^7.23.9", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "readdirp": "^4.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">= 14.16.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "url": "https://paulmillr.com/funding/" } }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, - "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" + "node_modules/@angular/compiler-cli/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/@angular/core": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-17.3.4.tgz", - "integrity": "sha512-fvhBkfa/DDBzp1UcNzSxHj+Z9DebSS/o9pZpZlbu/0uEiu9hScmScnhaty5E0EbutzHB0SVUCz7zZuDeAywvWg==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.5.tgz", + "integrity": "sha512-NNEz1sEZz1mBpgf6Tz3aJ9b8KjqpTiMYhHfCYA9h9Ipe4D8gUmOsvPHPK2M755OX7p7PmUmzp1XCUHYrZMVHRw==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.14.0" + "zone.js": "~0.15.0" } }, "node_modules/@angular/forms": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.3.4.tgz", - "integrity": "sha512-XWA/FAs0r7VRdztMIfGU9EE0Chj+1U/sDnzJK3ZPO0n8F8oDAEWGJyiw8GIyWTLs+mz43thVIED3DhbRNsXbWw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.5.tgz", + "integrity": "sha512-2Zvy3qK1kOxiAX9fdSaeG48q7oyO/4RlMYlg1w+ra9qX1SrgwF3OQ2P2Vs+ojg1AxN3z9xFp4aYaaID/G2LZAw==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "17.3.4", - "@angular/core": "17.3.4", - "@angular/platform-browser": "17.3.4", + "@angular/common": "19.2.5", + "@angular/core": "19.2.5", + "@angular/platform-browser": "19.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/localize": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-17.3.4.tgz", - "integrity": "sha512-sNViKDiu/sdeaeyOYSdaifigdj1hjwcivxEoqw2k/GI4hlVtEtOZrqZUfgT4PycGpE4mizdwgRYX+NvKY0D5uQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.2.5.tgz", + "integrity": "sha512-oAc19bubk6Z/2Vv6OkV0MsjdgC8cUaUwBmwdc6blFVe1NCX1KjdaqDyC2EQAO3nWfcdV4uvOOuu8myxB64bamw==", "dependencies": { - "@babel/core": "7.23.9", + "@babel/core": "7.26.9", "@types/babel__core": "7.20.5", - "fast-glob": "3.3.2", + "fast-glob": "3.3.3", "yargs": "^17.2.1" }, "bin": { @@ -617,69 +641,27 @@ "localize-translate": "tools/bundles/src/translate/cli.js" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "17.3.4", - "@angular/compiler-cli": "17.3.4" - } - }, - "node_modules/@angular/localize/node_modules/@babel/core": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", - "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.9", - "@babel/parser": "^7.23.9", - "@babel/template": "^7.23.9", - "@babel/traverse": "^7.23.9", - "@babel/types": "^7.23.9", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@angular/localize/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, - "node_modules/@angular/localize/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" + "@angular/compiler": "19.2.5", + "@angular/compiler-cli": "19.2.5" } }, "node_modules/@angular/platform-browser": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.3.4.tgz", - "integrity": "sha512-W2nH9WSQJfdNG4HH9B1Cvj5CTmy9gF3321I+65Tnb8jFmpeljYDBC/VVUhTZUCRpg8udMWeMHEQHuSb8CbozmQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.5.tgz", + "integrity": "sha512-Lshy++X16cvl6OPvfzMySpsqEaCPKEJmDjz7q7oSt96oxlh6LvOeOUVLjsNyrNaIt9NadpWoqjlu/I9RTPJkpw==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "17.3.4", - "@angular/common": "17.3.4", - "@angular/core": "17.3.4" + "@angular/animations": "19.2.5", + "@angular/common": "19.2.5", + "@angular/core": "19.2.5" }, "peerDependenciesMeta": { "@angular/animations": { @@ -688,45 +670,46 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.3.4.tgz", - "integrity": "sha512-S53jPyQtInVYkjdGEFt4dxM1NrHNkWCvXGRsCO7Uh+laDf1OpIDp9YHf49OZohYLajJradN6y4QfdZL6IUwXKA==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.5.tgz", + "integrity": "sha512-15in8u4552EcdWNTXY2h0MKuJbk3AuXwWr0zVTum4CfB/Ss2tNTrDEdWhgAbhnUI0e9jZQee/fhBbA1rleMYrA==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "17.3.4", - "@angular/compiler": "17.3.4", - "@angular/core": "17.3.4", - "@angular/platform-browser": "17.3.4" + "@angular/common": "19.2.5", + "@angular/compiler": "19.2.5", + "@angular/core": "19.2.5", + "@angular/platform-browser": "19.2.5" } }, "node_modules/@angular/router": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-17.3.4.tgz", - "integrity": "sha512-B1zjUYyhN66dp47zdF96NRwo0dEdM5In4Ob8HN64PAbnaK3y1EPp31aN6EGernPvKum1ibgwSZw+Uwnbkuv7Ww==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.5.tgz", + "integrity": "sha512-9pSfmdNXLjaOKj0kd4UxBC7sFdCFOnRGbftp397G3KWqsLsGSKmNFzqhXNeA5QHkaVxnpmpm8HzXU+zYV5JwSg==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "17.3.4", - "@angular/core": "17.3.4", - "@angular/platform-browser": "17.3.4", + "@angular/common": "19.2.5", + "@angular/core": "19.2.5", + "@angular/platform-browser": "19.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@babel/code-frame": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dependencies": { - "@babel/highlight": "^7.24.2", + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", "picocolors": "^1.0.0" }, "engines": { @@ -734,28 +717,28 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", - "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", - "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.0", - "@babel/parser": "^7.24.0", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.0", - "@babel/types": "^7.24.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -784,51 +767,40 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz", + "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" + "@babel/parser": "^7.26.10", + "@babel/types": "^7.26.10", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", - "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.15" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -844,144 +816,26 @@ "semver": "bin/semver.js" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.4.tgz", - "integrity": "sha512-lG75yeuUSVu0pIcbhiYMXBXANHrpUPaOfu7ryAzskCgKUHuAxRQI5ssrtmF0X9UXldPlvT0XM/A4F44OXRt6iQ==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-member-expression-to-functions": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.24.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", - "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "regexpu-core": "^5.3.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.1.tgz", - "integrity": "sha512-o7SDgTJuvx5vLKD6SFvkydkSMBvahDKGiNJzG22IZYXhiqoe9efY7zocICBgzHV4IRg5wdgl2nEL/tulKIEIbA==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", - "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", - "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", "dependencies": { - "@babel/types": "^7.24.0" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -990,164 +844,70 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", - "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", - "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", "dev": true, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", - "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-wrap-function": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.1.tgz", - "integrity": "sha512-QCR1UqC9BzG5vZl8BMicmZ28RuUBnHhAMddD8yHFHDRH9lLTZ9uUPehX8ctVPT8l0TKblJidqcgUUKGVrePleQ==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-member-expression-to-functions": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", - "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", - "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", - "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", - "dev": true, - "dependencies": { - "@babel/helper-function-name": "^7.22.5", - "@babel/template": "^7.22.15", - "@babel/types": "^7.22.19" - }, + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", - "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", + "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "dependencies": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", - "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", - "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", + "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "dependencies": { + "@babel/types": "^7.26.10" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -1155,151 +915,13 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.24.1.tgz", - "integrity": "sha512-y4HqEnkelJIOQGd+3g1bTeKsA5c6qM7eOn7VggGVbBc0y8MLSKHacwcIE2PplNlQSj0PqS9rrXL/nkPVK+kUNg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.1.tgz", - "integrity": "sha512-Hj791Ii4ci8HqnaKHAlLNs+zaLXb0EzSDhiAWp5VNlyvCNymYfacs64pxTxbH1znW/NcArSmwpmG9IKE/TUVVQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.24.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.24.1.tgz", - "integrity": "sha512-m9m/fXsXLiHfwdgydIFnpk+7jlVbnvlK5B2EKiPdLUb6WX654ZaaEWJUjk8TftRbZpK0XibovlLWX4KIZhV6jw==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-export-namespace-from": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", - "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.1.tgz", - "integrity": "sha512-IuwnI5XnuF189t91XbxmXeCDz3qs6iDRO7GJ++wcfgeXNs/8FmIlKcpDSXNVyuLQxlwvskmI3Ct73wUODkJBlQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.1.tgz", - "integrity": "sha512-zhQTMH0X2nVLnb04tz+s7AMuasX8U0FnpE+nHTOhSOINjWMnopoZTxtIKsd45n4GQ/HIZLyfIpoul8e2m0DnRA==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1308,1109 +930,29 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.1.tgz", - "integrity": "sha512-ngT/3NkRhsaep9ck9uj2Xhv9+xB1zShY3tM3g6om4xxCELwCDN4g4Aq5dRn48+0hasAql7s2hdBOysCfNpr4fw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.9.tgz", - "integrity": "sha512-8Q3veQEDGe14dTYuwagbRtwxQDnytyg1JFu4/HwEMETeofocrB0U0ejBJIXoeG/t2oXZ8kzCyI0ZZfbT80VFNQ==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.20", - "@babel/plugin-syntax-async-generators": "^7.8.4" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz", - "integrity": "sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.1.tgz", - "integrity": "sha512-TWWC18OShZutrv9C6mye1xwtam+uNi2bnTOCBUd5sZxyHOiWbU6ztSROofIMrK84uweEZC219POICK/sTYwfgg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.24.4.tgz", - "integrity": "sha512-nIFUZIpGKDf9O9ttyRXpHFpKC+X3Y5mtshZONuEUYBomAKoM4y029Jr+uB1bHGPhNmK8YXHevDtKDOLmtRrp6g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.1.tgz", - "integrity": "sha512-OMLCXi0NqvJfORTaPQBwqLXHhb93wkBKZ4aNwMl6WtehO7ar+cmp+89iPEQPqxAnxsOKTaMcs3POz3rKayJ72g==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.1", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.4.tgz", - "integrity": "sha512-B8q7Pz870Hz/q9UgP8InNpY01CSLDSCyqX7zcRuv3FcPl87A2G17lASroHWaCtbdIcbYzOZ7kWmXFKbijMSmFg==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.4", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-class-static-block": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.1.tgz", - "integrity": "sha512-ZTIe3W7UejJd3/3R4p7ScyyOoafetUShSf4kCqV0O7F/RiHxVj/wRaRnQlrGwflvcehNA8M42HkAiEDYZu2F1Q==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-replace-supers": "^7.24.1", - "@babel/helper-split-export-declaration": "^7.22.6", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.1.tgz", - "integrity": "sha512-5pJGVIUfJpOS+pAqBQd+QMaTD2vCL/HcePooON6pDpHgRp4gNRmzyHTPIkXntwKsq3ayUFVfJaIKPw2pOkOcTw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/template": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.1.tgz", - "integrity": "sha512-ow8jciWqNxR3RYbSNVuF4U2Jx130nwnBnhRw6N6h1bOejNkABmcI5X5oz29K4alWX7vf1C+o6gtKXikzRKkVdw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.1.tgz", - "integrity": "sha512-p7uUxgSoZwZ2lPNMzUkqCts3xlp8n+o05ikjy7gbtFJSt9gdU88jAmtfmOxHM14noQXBxfgzf2yRWECiNVhTCw==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.1.tgz", - "integrity": "sha512-msyzuUnvsjsaSaocV6L7ErfNsa5nDWL1XKNnDePLgmz+WdU4w/J8+AxBMrWfi9m4IxfL5sZQKUPQKDQeeAT6lA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.1.tgz", - "integrity": "sha512-av2gdSTyXcJVdI+8aFZsCAtR29xJt0S5tas+Ef8NvBNmD1a+N/3ecMLeMBgfcK+xzsjdLDT6oHt+DFPyeqUbDA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-dynamic-import": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.1.tgz", - "integrity": "sha512-U1yX13dVBSwS23DEAqU+Z/PkwE9/m7QQy8Y9/+Tdb8UWYaGNDYwTLi19wqIAiROr8sXVum9A/rtiH5H0boUcTw==", - "dev": true, - "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.1.tgz", - "integrity": "sha512-Ft38m/KFOyzKw2UaJFkWG9QnHPG/Q/2SkOrRk4pNBPg5IPZ+dOxcmkK5IyuBcxiNPyyYowPGUReyBvrvZs7IlQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.1.tgz", - "integrity": "sha512-OxBdcnF04bpdQdR3i4giHZNZQn7cm8RQKcSwA17wAAqEELo1ZOwp5FFgeptWUQXFyT9kwHo10aqqauYkRZPCAg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.24.1.tgz", - "integrity": "sha512-BXmDZpPlh7jwicKArQASrj8n22/w6iymRnvHYYd2zO30DbE277JO20/7yXJT3QxDPtiQiOxQBbZH4TpivNXIxA==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.1.tgz", - "integrity": "sha512-U7RMFmRvoasscrIFy5xA4gIp8iWnWubnKkKuUGJjsuOH7GfbMkB+XZzeslx2kLdEGdOJDamEmCqOks6e8nv8DQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-json-strings": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.24.1.tgz", - "integrity": "sha512-zn9pwz8U7nCqOYIiBaOxoQOtYmMODXTJnkxG4AtX8fPmnCRYWBOHD0qcpwS9e2VDSp1zNJYpdnFMIKb8jmwu6g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.1.tgz", - "integrity": "sha512-OhN6J4Bpz+hIBqItTeWJujDOfNP+unqv/NJgyhlpSqgBTPm37KkMmZV6SYcOj+pnDbdcl1qRGV/ZiIjX9Iy34w==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.1.tgz", - "integrity": "sha512-4ojai0KysTWXzHseJKa1XPNXKRbuUrhkOPY4rEGeR+7ChlJVKxFa3H3Bz+7tWaGKgJAXUWKOGmltN+u9B3+CVg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.1.tgz", - "integrity": "sha512-lAxNHi4HVtjnHd5Rxg3D5t99Xm6H7b04hUS7EHIXcUl2EV4yl1gWdqZrNzXnSrHveL9qMdbODlLF55mvgjAfaQ==", - "dev": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.1.tgz", - "integrity": "sha512-szog8fFTUxBfw0b98gEWPaEqF42ZUD/T3bkynW/wtgx2p/XCP55WEsb+VosKceRSd6njipdZvNogqdtI4Q0chw==", - "dev": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-simple-access": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.24.1.tgz", - "integrity": "sha512-mqQ3Zh9vFO1Tpmlt8QPnbwGHzNz3lpNEMxQb1kAemn/erstyqw1r9KeOlOfo3y6xAnFEcOv2tSyrXfmMk+/YZA==", - "dev": true, - "dependencies": { - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-validator-identifier": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.1.tgz", - "integrity": "sha512-tuA3lpPj+5ITfcCluy6nWonSL7RvaG0AOTeAuvXqEKS34lnLzXpDb0dcP6K8jD0zWZFNDVly90AGFJPnm4fOYg==", - "dev": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", - "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.1.tgz", - "integrity": "sha512-/rurytBM34hYy0HKZQyA0nHbQgQNFm4Q/BOc9Hflxi2X3twRof7NaE5W46j4kQitm7SvACVRXsa6N/tSZxvPug==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.1.tgz", - "integrity": "sha512-iQ+caew8wRrhCikO5DrUYx0mrmdhkaELgFa+7baMcVuhxIkN7oxt06CZ51D65ugIb1UWRQ8oQe+HXAVM6qHFjw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.1.tgz", - "integrity": "sha512-7GAsGlK4cNL2OExJH1DzmDeKnRv/LXq0eLUSvudrehVA5Rgg4bIrqEUW29FbKMBRT0ztSqisv7kjP+XIC4ZMNw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-numeric-separator": "^7.10.4" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.1.tgz", - "integrity": "sha512-XjD5f0YqOtebto4HGISLNfiNMTTs6tbkFf2TOqJlYKYmbo+mN9Dnpl4SRoofiziuOWMIyq3sZEUqLo3hLITFEA==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.24.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.1.tgz", - "integrity": "sha512-oKJqR3TeI5hSLRxudMjFQ9re9fBVUU0GICqM3J1mi8MqlhVr6hC/ZN4ttAyMuQR6EZZIY6h/exe5swqGNNIkWQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-replace-supers": "^7.24.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.1.tgz", - "integrity": "sha512-oBTH7oURV4Y+3EUrf6cWn1OHio3qG/PVwO5J03iSJmBg6m2EhKjkAu/xuaXaYwWW9miYtvbWv4LNf0AmR43LUA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.1.tgz", - "integrity": "sha512-n03wmDt+987qXwAgcBlnUUivrZBPZ8z1plL0YvgQalLm+ZE5BMhGm94jhxXtA1wzv1Cu2aaOv1BM9vbVttrzSg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.1.tgz", - "integrity": "sha512-8Jl6V24g+Uw5OGPeWNKrKqXPDw2YDjLc53ojwfMcKwlEoETKU9rU0mHUtcg9JntWI/QYzGAXNWEcVHZ+fR+XXg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.1.tgz", - "integrity": "sha512-tGvisebwBO5em4PaYNqt4fkw56K2VALsAbAakY0FjTYqJp7gfdrgr7YX76Or8/cpik0W6+tj3rZ0uHU9Oil4tw==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.1", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.1.tgz", - "integrity": "sha512-pTHxDVa0BpUbvAgX3Gat+7cSciXqUcY9j2VZKTbSB6+VQGpNgNO9ailxTGHSXlqOnX1Hcx1Enme2+yv7VqP9bg==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.24.1", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.1.tgz", - "integrity": "sha512-LetvD7CrHmEx0G442gOomRr66d7q8HzzGGr4PMHGr+5YIm6++Yke+jxj246rpvsbyhJwCLxcTn6zW1P1BSenqA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.1.tgz", - "integrity": "sha512-sJwZBCzIBE4t+5Q4IGLaaun5ExVMRY0lYwos/jNecjMrVCygCdph3IKv0tkP5Fc87e/1+bebAmEAGBfnRD+cnw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "regenerator-transform": "^0.15.2" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.1.tgz", - "integrity": "sha512-JAclqStUfIwKN15HrsQADFgeZt+wexNQ0uLhuqvqAUFoqPMjEcFCYZBhq0LUdz6dZK/mD+rErhW71fbx8RYElg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.24.0.tgz", - "integrity": "sha512-zc0GA5IitLKJrSfXlXmp8KDqLrnGECK7YRfQBmEKg1NmBOQ7e+KuclBEKJgzifQeUYLdNiAw4B4bjyvzWVLiSA==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0", - "babel-plugin-polyfill-corejs2": "^0.4.8", - "babel-plugin-polyfill-corejs3": "^0.9.0", - "babel-plugin-polyfill-regenerator": "^0.5.5", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.1.tgz", - "integrity": "sha512-LyjVB1nsJ6gTTUKRjRWx9C1s9hE7dLfP/knKdrfeH9UPtAGjYGgxIbFfx7xyLIEWs7Xe1Gnf8EWiUqfjLhInZA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.1.tgz", - "integrity": "sha512-KjmcIM+fxgY+KxPVbjelJC6hrH1CgtPmTvdXAfn3/a9CnWGSTY7nH4zm5+cjmWJybdcPSsD0++QssDsjcpe47g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.1.tgz", - "integrity": "sha512-9v0f1bRXgPVcPrngOQvLXeGNNVLc8UjMVfebo9ka0WF3/7+aVUHmaJVT3sa0XCzEFioPfPHZiOcYG9qOsH63cw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.1.tgz", - "integrity": "sha512-WRkhROsNzriarqECASCNu/nojeXCDTE/F2HmRgOzi7NGvyfYGq1NEjKBK3ckLfRgGc6/lPAqP0vDOSw3YtG34g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.1.tgz", - "integrity": "sha512-CBfU4l/A+KruSUoW+vTQthwcAdwuqbpRNB8HQKlZABwHRhsdHZ9fezp4Sn18PeAlYxTNiLMlx4xUBV3AWfg1BA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.1.tgz", - "integrity": "sha512-RlkVIcWT4TLI96zM660S877E7beKlQw7Ig+wqkKBiWfj0zH5Q4h50q6er4wzZKRNSYpfo6ILJ+hrJAGSX2qcNw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.1.tgz", - "integrity": "sha512-Ss4VvlfYV5huWApFsF8/Sq0oXnGO+jB+rijFEFugTd3cwSObUSnUi88djgR5528Csl0uKlrI331kRqe56Ov2Ng==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.1.tgz", - "integrity": "sha512-2A/94wgZgxfTsiLaQ2E36XAOdcZmGAaEEgVmxQWwZXWkGhvoHbaqXcKnU8zny4ycpu3vNqg0L/PcCiYtHtA13g==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.1.tgz", - "integrity": "sha512-fqj4WuzzS+ukpgerpAoOnMfQXwUHFxXUZUE84oL2Kao2N8uSlvcpnAidKASgsNgzZHBsHWvcm8s9FPWUhAb8fA==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.0.tgz", - "integrity": "sha512-ZxPEzV9IgvGn73iK0E6VB9/95Nd7aMFpbE0l8KQFDG70cOV9IxRP7Y2FUPmlK0v6ImlLqYX50iuZ3ZTVhOF2lA==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-plugin-utils": "^7.24.0", - "@babel/helper-validator-option": "^7.23.5", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.7", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.23.3", - "@babel/plugin-syntax-import-attributes": "^7.23.3", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.23.3", - "@babel/plugin-transform-async-generator-functions": "^7.23.9", - "@babel/plugin-transform-async-to-generator": "^7.23.3", - "@babel/plugin-transform-block-scoped-functions": "^7.23.3", - "@babel/plugin-transform-block-scoping": "^7.23.4", - "@babel/plugin-transform-class-properties": "^7.23.3", - "@babel/plugin-transform-class-static-block": "^7.23.4", - "@babel/plugin-transform-classes": "^7.23.8", - "@babel/plugin-transform-computed-properties": "^7.23.3", - "@babel/plugin-transform-destructuring": "^7.23.3", - "@babel/plugin-transform-dotall-regex": "^7.23.3", - "@babel/plugin-transform-duplicate-keys": "^7.23.3", - "@babel/plugin-transform-dynamic-import": "^7.23.4", - "@babel/plugin-transform-exponentiation-operator": "^7.23.3", - "@babel/plugin-transform-export-namespace-from": "^7.23.4", - "@babel/plugin-transform-for-of": "^7.23.6", - "@babel/plugin-transform-function-name": "^7.23.3", - "@babel/plugin-transform-json-strings": "^7.23.4", - "@babel/plugin-transform-literals": "^7.23.3", - "@babel/plugin-transform-logical-assignment-operators": "^7.23.4", - "@babel/plugin-transform-member-expression-literals": "^7.23.3", - "@babel/plugin-transform-modules-amd": "^7.23.3", - "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/plugin-transform-modules-systemjs": "^7.23.9", - "@babel/plugin-transform-modules-umd": "^7.23.3", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", - "@babel/plugin-transform-new-target": "^7.23.3", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", - "@babel/plugin-transform-numeric-separator": "^7.23.4", - "@babel/plugin-transform-object-rest-spread": "^7.24.0", - "@babel/plugin-transform-object-super": "^7.23.3", - "@babel/plugin-transform-optional-catch-binding": "^7.23.4", - "@babel/plugin-transform-optional-chaining": "^7.23.4", - "@babel/plugin-transform-parameters": "^7.23.3", - "@babel/plugin-transform-private-methods": "^7.23.3", - "@babel/plugin-transform-private-property-in-object": "^7.23.4", - "@babel/plugin-transform-property-literals": "^7.23.3", - "@babel/plugin-transform-regenerator": "^7.23.3", - "@babel/plugin-transform-reserved-words": "^7.23.3", - "@babel/plugin-transform-shorthand-properties": "^7.23.3", - "@babel/plugin-transform-spread": "^7.23.3", - "@babel/plugin-transform-sticky-regex": "^7.23.3", - "@babel/plugin-transform-template-literals": "^7.23.3", - "@babel/plugin-transform-typeof-symbol": "^7.23.3", - "@babel/plugin-transform-unicode-escapes": "^7.23.3", - "@babel/plugin-transform-unicode-property-regex": "^7.23.3", - "@babel/plugin-transform-unicode-regex": "^7.23.3", - "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.8", - "babel-plugin-polyfill-corejs3": "^0.9.0", - "babel-plugin-polyfill-regenerator": "^0.5.5", - "core-js-compat": "^3.31.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", - "dev": true - }, - "node_modules/@babel/runtime": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz", - "integrity": "sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==", - "dev": true, - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", - "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz", + "integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==", "dependencies": { - "@babel/code-frame": "^7.24.1", - "@babel/generator": "^7.24.1", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.1", - "@babel/types": "^7.24.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2418,28 +960,13 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/@babel/generator": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", - "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", - "dependencies": { - "@babel/types": "^7.24.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", + "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2477,9 +1004,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.1.tgz", - "integrity": "sha512-m55cpeupQ2DbuRGQMMZDzbv9J9PgVelPjlcmM5kxHnrBdBx6REaEd7LamYV7Dm8N7rCyR/XwU6rVP8ploKtIkA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", + "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", "cpu": [ "ppc64" ], @@ -2489,13 +1016,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.1.tgz", - "integrity": "sha512-4j0+G27/2ZXGWR5okcJi7pQYhmkVgb4D7UKwxcqrjhvp5TKWx3cUjgB1CGj1mfdmJBQ9VnUGgUhign+FPF2Zgw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", + "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", "cpu": [ "arm" ], @@ -2505,13 +1032,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.1.tgz", - "integrity": "sha512-hCnXNF0HM6AjowP+Zou0ZJMWWa1VkD77BXe959zERgGJBBxB+sV+J9f/rcjeg2c5bsukD/n17RKWXGFCO5dD5A==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", + "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", "cpu": [ "arm64" ], @@ -2521,13 +1048,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.1.tgz", - "integrity": "sha512-MSfZMBoAsnhpS+2yMFYIQUPs8Z19ajwfuaSZx+tSl09xrHZCjbeXXMsUF/0oq7ojxYEpsSo4c0SfjxOYXRbpaA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", + "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", "cpu": [ "x64" ], @@ -2537,13 +1064,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.1.tgz", - "integrity": "sha512-Ylk6rzgMD8klUklGPzS414UQLa5NPXZD5tf8JmQU8GQrj6BrFA/Ic9tb2zRe1kOZyCbGl+e8VMbDRazCEBqPvA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", + "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", "cpu": [ "arm64" ], @@ -2553,13 +1080,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.1.tgz", - "integrity": "sha512-pFIfj7U2w5sMp52wTY1XVOdoxw+GDwy9FsK3OFz4BpMAjvZVs0dT1VXs8aQm22nhwoIWUmIRaE+4xow8xfIDZA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", + "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", "cpu": [ "x64" ], @@ -2569,13 +1096,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.1.tgz", - "integrity": "sha512-UyW1WZvHDuM4xDz0jWun4qtQFauNdXjXOtIy7SYdf7pbxSWWVlqhnR/T2TpX6LX5NI62spt0a3ldIIEkPM6RHw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", + "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", "cpu": [ "arm64" ], @@ -2585,13 +1112,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.1.tgz", - "integrity": "sha512-itPwCw5C+Jh/c624vcDd9kRCCZVpzpQn8dtwoYIt2TJF3S9xJLiRohnnNrKwREvcZYx0n8sCSbvGH349XkcQeg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", + "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", "cpu": [ "x64" ], @@ -2601,13 +1128,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.1.tgz", - "integrity": "sha512-LojC28v3+IhIbfQ+Vu4Ut5n3wKcgTu6POKIHN9Wpt0HnfgUGlBuyDDQR4jWZUZFyYLiz4RBBBmfU6sNfn6RhLw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", + "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", "cpu": [ "arm" ], @@ -2617,13 +1144,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.1.tgz", - "integrity": "sha512-cX8WdlF6Cnvw/DO9/X7XLH2J6CkBnz7Twjpk56cshk9sjYVcuh4sXQBy5bmTwzBjNVZze2yaV1vtcJS04LbN8w==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", + "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", "cpu": [ "arm64" ], @@ -2633,13 +1160,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.1.tgz", - "integrity": "sha512-4H/sQCy1mnnGkUt/xszaLlYJVTz3W9ep52xEefGtd6yXDQbz/5fZE5dFLUgsPdbUOQANcVUa5iO6g3nyy5BJiw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", + "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", "cpu": [ "ia32" ], @@ -2649,13 +1176,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.1.tgz", - "integrity": "sha512-c0jgtB+sRHCciVXlyjDcWb2FUuzlGVRwGXgI+3WqKOIuoo8AmZAddzeOHeYLtD+dmtHw3B4Xo9wAUdjlfW5yYA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", + "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", "cpu": [ "loong64" ], @@ -2665,13 +1192,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.1.tgz", - "integrity": "sha512-TgFyCfIxSujyuqdZKDZ3yTwWiGv+KnlOeXXitCQ+trDODJ+ZtGOzLkSWngynP0HZnTsDyBbPy7GWVXWaEl6lhA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", + "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", "cpu": [ "mips64el" ], @@ -2681,13 +1208,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.1.tgz", - "integrity": "sha512-b+yuD1IUeL+Y93PmFZDZFIElwbmFfIKLKlYI8M6tRyzE6u7oEP7onGk0vZRh8wfVGC2dZoy0EqX1V8qok4qHaw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", + "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", "cpu": [ "ppc64" ], @@ -2697,13 +1224,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.1.tgz", - "integrity": "sha512-wpDlpE0oRKZwX+GfomcALcouqjjV8MIX8DyTrxfyCfXxoKQSDm45CZr9fanJ4F6ckD4yDEPT98SrjvLwIqUCgg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", + "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", "cpu": [ "riscv64" ], @@ -2713,13 +1240,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.1.tgz", - "integrity": "sha512-5BepC2Au80EohQ2dBpyTquqGCES7++p7G+7lXe1bAIvMdXm4YYcEfZtQrP4gaoZ96Wv1Ute61CEHFU7h4FMueQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", + "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", "cpu": [ "s390x" ], @@ -2729,13 +1256,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.1.tgz", - "integrity": "sha512-5gRPk7pKuaIB+tmH+yKd2aQTRpqlf1E4f/mC+tawIm/CGJemZcHZpp2ic8oD83nKgUPMEd0fNanrnFljiruuyA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", + "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", "cpu": [ "x64" ], @@ -2745,13 +1272,29 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", + "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.1.tgz", - "integrity": "sha512-4fL68JdrLV2nVW2AaWZBv3XEm3Ae3NZn/7qy2KGAt3dexAgSVT+Hc97JKSZnqezgMlv9x6KV0ZkZY7UO5cNLCg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", + "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", "cpu": [ "x64" ], @@ -2761,13 +1304,29 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", + "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.1.tgz", - "integrity": "sha512-GhRuXlvRE+twf2ES+8REbeCb/zeikNqwD3+6S5y5/x+DYbAQUNl0HNBs4RQJqrechS4v4MruEr8ZtAin/hK5iw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", + "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", "cpu": [ "x64" ], @@ -2777,13 +1336,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.1.tgz", - "integrity": "sha512-ZnWEyCM0G1Ex6JtsygvC3KUUrlDXqOihw8RicRuQAzw+c4f1D66YlPNNV3rkjVW90zXVsHwZYWbJh3v+oQFM9Q==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", + "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", "cpu": [ "x64" ], @@ -2793,13 +1352,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.1.tgz", - "integrity": "sha512-QZ6gXue0vVQY2Oon9WyLFCdSuYbXSoxaZrPuJ4c20j6ICedfsDilNPYfHLlMH7vGfU5DQR0czHLmJvH4Nzis/A==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", + "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", "cpu": [ "arm64" ], @@ -2809,13 +1368,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.1.tgz", - "integrity": "sha512-HzcJa1NcSWTAU0MJIxOho8JftNp9YALui3o+Ny7hCh0v5f90nprly1U3Sj1Ldj/CvKKdvvFsCRvDkpsEMp4DNw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", + "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", "cpu": [ "ia32" ], @@ -2825,13 +1384,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.1.tgz", - "integrity": "sha512-0MBh53o6XtI6ctDnRMeQ+xoCN8kD2qI1rY1KgF/xdWQwoFeKou7puvDfV8/Wv4Ctx2rRpET/gGdz3YlNtNACSA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", + "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", "cpu": [ "x64" ], @@ -2841,7 +1400,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -2860,24 +1419,81 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.0.tgz", + "integrity": "sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -2885,7 +1501,7 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2907,12 +1523,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2924,32 +1534,17 @@ } }, "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2968,70 +1563,78 @@ "node": "*" } }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "node_modules/@eslint/js": { + "version": "9.23.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", + "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", "dev": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", + "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.12.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz", - "integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==", - "hasInstallScript": true, + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", "engines": { "node": ">=6" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, "engines": { - "node": ">=10.10.0" + "node": ">=18.18.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" }, "engines": { - "node": "*" + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@humanwhocodes/module-importer": { @@ -3047,35 +1650,346 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "dev": true + "node_modules/@humanwhocodes/retry": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@iharbeck/ngx-virtual-scroller": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/@iharbeck/ngx-virtual-scroller/-/ngx-virtual-scroller-17.0.2.tgz", - "integrity": "sha512-ixB4NZ4kwboYC/prWNkO7MohTx9pEilcBMQOmSxBn92P4GGbu5+HjiCDe+v5fj31bGIDiya2xXWouWkcuG/Y+w==", + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/@iharbeck/ngx-virtual-scroller/-/ngx-virtual-scroller-19.0.1.tgz", + "integrity": "sha512-dtn4CpbEY92H9nd1A48WNhsyUgtFBjC83xcsc9VzlSQT/KN2fEx0oBs0Obnn6ZdPanDP/IQdlBgmANmlds/wHA==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@tweenjs/tween.js": "^23.1.1" + "@tweenjs/tween.js": "^25.0.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.4.tgz", + "integrity": "sha512-d30576EZdApjAMceijXA5jDzRQHT/MygbC+J8I7EqA6f/FRpYxlRtRJbHF8gHeWYeSdOuTEJqonn7QLB1ELezA==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", + "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.9.tgz", + "integrity": "sha512-sXhVB8n20NYkUBfDYgizGHlpRVaCRjtuzNZA6xpALIUbkgfd2Hjz+DfEN6+h1BRnuxw0/P4jCIMjMsEOAMwAJw==", + "dev": true, + "dependencies": { + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.9.tgz", + "integrity": "sha512-8HjOppAxO7O4wV1ETUlJFg6NDjp/W2NP5FB9ZPAcinAlNT4ZIWOLe2pUVwmmPRSV0NMdI5r/+lflN55AwZOKSw==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", + "external-editor": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.11.tgz", + "integrity": "sha512-OZSUW4hFMW2TYvX/Sv+NnOZgO8CHT2TU1roUCUIF2T+wfw60XFRRp9MRUPCT06cRnKL+aemt2YmTWwt7rOrNEA==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", + "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.8.tgz", + "integrity": "sha512-WXJI16oOZ3/LiENCAxe8joniNp8MQxF6Wi5V+EBbVA0ZIOpFcL4I9e7f7cXse0HJeIPCWO8Lcgnk98juItCi7Q==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.11.tgz", + "integrity": "sha512-pQK68CsKOgwvU2eA53AG/4npRTH2pvs/pZ2bFvzpBhrznh8Mcwt19c+nMO7LHRr3Vreu1KPhNBF3vQAKrjIulw==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.11.tgz", + "integrity": "sha512-dH6zLdv+HEv1nBs96Case6eppkRggMe8LoOTl30+Gq5Wf27AO/vHFgStTVz4aoevLdNXqwE23++IXGw4eiOXTg==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.11.tgz", + "integrity": "sha512-uAYtTx0IF/PqUAvsRrF3xvnxJV516wmR6YVONOmCWJbbt87HcDHLfL9wmBQFbNJRv5kCjdYKrZcavDkH3sVJPg==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.11.tgz", + "integrity": "sha512-9CWQT0ikYcg6Ls3TOa7jljsD7PgjcsYEM0bYE+Gkz+uoW9u8eaJCRHJKkucpRE5+xKtaaDbrND+nPDoxzjYyew==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.1.0.tgz", + "integrity": "sha512-z0a2fmgTSRN+YBuiK1ROfJ2Nvrpij5lVN3gPDkQGhavdvIVGHGW29LwYZfM/j42Ai2hUghTI/uoBuTbrJk42bA==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.5.tgz", + "integrity": "sha512-ZJpeIYYueOz/i/ONzrfof8g89kNdO2hjGuvULROo3O8rlB2CRtSseE5KeirnyE4t/thAn/EwvS/vuQeJCn+NZg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@iplab/ngx-file-upload": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@iplab/ngx-file-upload/-/ngx-file-upload-17.1.0.tgz", - "integrity": "sha512-5YBhPra6ZJ0oYQBYUCPdGd+yoWW08QfuHI31MoCETil/9RG92btEfX5PcSVFY8IkOJAu9sgk83Y4WI5TEBM6lw==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@iplab/ngx-file-upload/-/ngx-file-upload-19.0.3.tgz", + "integrity": "sha512-PXQroFbMrQwg69b/j6Im9R8DkLz15YxiA0ATlFpOTPRtDhAWQMIRNdxbcqRLmBLdPvrsXpH/gN30f0GyC1k/fw==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/animations": "^17.0.0", - "@angular/common": "^17.0.0", - "@angular/core": "^17.0.0", - "@angular/forms": "^17.0.0", + "@angular/animations": "^19.0.0", + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0", "rxjs": "^7.0.0" } }, @@ -3168,20 +2082,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dev": true, "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" + "minipass": "^7.0.4" }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, "node_modules/@istanbuljs/schema": { @@ -3193,18 +2103,6 @@ "node": ">=8" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -3234,20 +2132,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -3259,12 +2147,11 @@ } }, "node_modules/@jsverse/transloco": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@jsverse/transloco/-/transloco-7.4.3.tgz", - "integrity": "sha512-QVzpbsfMN4oB01OfiGBz0f9/cw6nczF2EHIlhJG0455bMjiaR/tQTVGFmAGnm267iQFPtOL36yQyaHznXxPaqw==", + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@jsverse/transloco/-/transloco-7.6.1.tgz", + "integrity": "sha512-hFFKJ1pVFYeW2E4UFETQpOeOn0tuncCSUdRewbq3LiV+qS9x4Z2XVuCaAaFPdiNhy4nUKHWFX1pWjpZ5XjUPaQ==", "dependencies": { "@jsverse/transloco-utils": "^7.0.0", - "flat": "6.0.1", "fs-extra": "^11.0.0", "glob": "^10.0.0", "lodash.kebabcase": "^4.1.1", @@ -3290,9 +2177,9 @@ } }, "node_modules/@jsverse/transloco-persist-lang": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@jsverse/transloco-persist-lang/-/transloco-persist-lang-7.0.1.tgz", - "integrity": "sha512-bCH5aECb6d/NbS3/oiTqgbWrMIzo1kJzJTOVF2uazZeMa8M4xcx1Am+cX/Fo4gxAFrrTLmI4yC2dde8JB/qdCA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-persist-lang/-/transloco-persist-lang-7.0.2.tgz", + "integrity": "sha512-VPB/IbukOS64RUM0NQk2rS/wmezo8JYucYerC/94nyF50LM5tR59SyJTPHSFHBTBqXykOQSXUxLRRwzt8UrfPg==", "dependencies": { "tslib": "^2.2.0" }, @@ -3337,28 +2224,124 @@ "node": ">=16" } }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "dev": true - }, - "node_modules/@ljharb/through": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.13.tgz", - "integrity": "sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ==", + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.18.tgz", + "integrity": "sha512-0hz44rAcrphyXcA8IS7EJ2SCoaBZD2u5goE8S/e+q/DL+dOGpqpcLidVOFeLG3VgML62SXmfRLAhWt0zL1oW4Q==", "dev": true, "dependencies": { - "call-bind": "^1.0.7" + "@inquirer/type": "^1.5.5" }, "engines": { - "node": ">= 0.4" + "node": ">=18.0.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 8" } }, + "node_modules/@listr2/prompt-adapter-inquirer/node_modules/@inquirer/type": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", + "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", + "dev": true, + "dependencies": { + "mute-stream": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer/node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.2.6.tgz", + "integrity": "sha512-yF/ih9EJJZc72psFQbwnn8mExIWfTnzWJg+N02hnpXtDPETYLmQswIMBn7+V88lfCaFrMozJsUvcEQIkEPU0Gg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.2.6.tgz", + "integrity": "sha512-5BbCumsFLbCi586Bb1lTWQFkekdQUw8/t8cy++Uq251cl3hbDIGEwD9HAwh8H6IS2F6QA9KdKmO136LmipRNkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.2.6.tgz", + "integrity": "sha512-+6XgLpMb7HBoWxXj+bLbiiB4s0mRRcDPElnRS3LpWRzdYSe+gFk5MT/4RrVNqd2MESUDmb53NUXw1+BP69bjiQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.2.6.tgz", + "integrity": "sha512-l5VmJamJ3nyMmeD1ANBQCQqy7do1ESaJQfKPSm2IG9/ADZryptTyCj8N6QaYgIWewqNUrcbdMkJajRQAt5Qjfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.2.6.tgz", + "integrity": "sha512-nDYT8qN9si5+onHYYaI4DiauDMx24OAiuZAUsEqrDy+ja/3EbpXPX/VAkMV8AEaQhy3xc4dRC+KcYIvOFefJ4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.2.6.tgz", + "integrity": "sha512-XlqVtILonQnG+9fH2N3Aytria7P/1fwDgDhl29rde96uH2sLB8CHORIf2PfuLVzFQJ7Uqp8py9AYwr3ZUCFfWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@microsoft/signalr": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.14.tgz", - "integrity": "sha512-dnS7gSJF5LxByZwJaj82+F1K755ya7ttPT+JnSeCBef3sL8p8FBkHePXphK8NSuOquIb7vsphXWa28A+L2SPpw==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.7.tgz", + "integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==", "dependencies": { "abort-controller": "^3.0.0", "eventsource": "^2.0.2", @@ -3367,38 +2350,388 @@ "ws": "^7.4.5" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", + "integrity": "sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.0.1", + "@napi-rs/nice-android-arm64": "1.0.1", + "@napi-rs/nice-darwin-arm64": "1.0.1", + "@napi-rs/nice-darwin-x64": "1.0.1", + "@napi-rs/nice-freebsd-x64": "1.0.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.0.1", + "@napi-rs/nice-linux-arm64-gnu": "1.0.1", + "@napi-rs/nice-linux-arm64-musl": "1.0.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.0.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.0.1", + "@napi-rs/nice-linux-s390x-gnu": "1.0.1", + "@napi-rs/nice-linux-x64-gnu": "1.0.1", + "@napi-rs/nice-linux-x64-musl": "1.0.1", + "@napi-rs/nice-win32-arm64-msvc": "1.0.1", + "@napi-rs/nice-win32-ia32-msvc": "1.0.1", + "@napi-rs/nice-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz", + "integrity": "sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz", + "integrity": "sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz", + "integrity": "sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz", + "integrity": "sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz", + "integrity": "sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@ng-bootstrap/ng-bootstrap": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-16.0.0.tgz", - "integrity": "sha512-+FJ3e6cX9DW2t7021Ji3oz433rk3+4jLfqzU+Jyx6/vJz1dIOaML3EAY6lYuW4TLiXgMPOMvs6KzPFALGh4Lag==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-18.0.0.tgz", + "integrity": "sha512-GeSAz4yiGq49psdte8kcf+Y562wB3jK/qKRAkh6iA32lcXmy2sfQXVAmlHdjZ3AyP+E8lf3yMwuPdSKiYcDgSg==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": "^17.0.0", - "@angular/core": "^17.0.0", - "@angular/forms": "^17.0.0", - "@angular/localize": "^17.0.0", + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0", + "@angular/localize": "^19.0.0", "@popperjs/core": "^2.11.8", "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@ngtools/webpack": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.4.tgz", - "integrity": "sha512-3uNX4tRTKPm91mSQcnmQtqDMMKLGDevJERSPJU7hlOXZZ05QrT4et1mwvXNYYMpXqi2OkC7D4ryIS2YxAiItBA==", - "dev": true, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "@angular/compiler-cli": "^17.0.0", - "typescript": ">=5.2 <5.5", - "webpack": "^5.54.0" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3432,9 +2765,9 @@ } }, "node_modules/@npmcli/agent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.2.tgz", - "integrity": "sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", "dev": true, "dependencies": { "agent-base": "^7.1.0", @@ -3444,47 +2777,44 @@ "socks-proxy-agent": "^8.0.3" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/@npmcli/fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz", - "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", "dev": true, "dependencies": { "semver": "^7.3.5" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/git": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.6.tgz", - "integrity": "sha512-4x/182sKXmQkf0EtXxT26GEsaOATpD7WVtza5hrYivWZeo6QefC6xq9KAXrnjtFKBZ4rZwR7aX/zClYYXgtwLw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", + "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", "dev": true, "dependencies": { - "@npmcli/promise-spawn": "^7.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", "lru-cache": "^10.0.1", - "npm-pick-manifest": "^9.0.0", - "proc-log": "^4.0.0", - "promise-inflight": "^1.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "semver": "^7.3.5", - "which": "^4.0.0" + "which": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/git/node_modules/isexe": { @@ -3497,27 +2827,15 @@ } }, "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } - }, - "node_modules/@npmcli/git/node_modules/proc-log": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.0.0.tgz", - "integrity": "sha512-v1lzmYxGDs2+OZnmYtYZK3DG8zogt+CbQ+o/iqqtTfpyCmGWulCTEQu5GIbivf7OjgIkH2Nr8SH8UxAGugZNbg==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/@npmcli/git/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -3526,71 +2844,62 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/installed-package-contents": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz", - "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", + "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", "dev": true, "dependencies": { - "npm-bundled": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" }, "bin": { - "installed-package-contents": "lib/index.js" + "installed-package-contents": "bin/index.js" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/node-gyp": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", - "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", + "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/package-json": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-5.0.3.tgz", - "integrity": "sha512-cgsjCvld2wMqkUqvY+SZI+1ZJ7umGBYc9IAKfqJRKJCcs7hCQYxScUgdsyrRINk3VmdCYf9TXiLBHQ6ECTxhtg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.1.tgz", + "integrity": "sha512-d5qimadRAUCO4A/Txw71VM7UrRZzV+NPclxz/dc+M6B2oYwjWTjqh8HA/sGQgs9VZuJ6I/P7XIAlJvgrl27ZOw==", "dev": true, "dependencies": { - "@npmcli/git": "^5.0.0", + "@npmcli/git": "^6.0.0", "glob": "^10.2.2", - "hosted-git-info": "^7.0.0", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^6.0.0", - "proc-log": "^4.0.0", - "semver": "^7.5.3" + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/package-json/node_modules/proc-log": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.0.0.tgz", - "integrity": "sha512-v1lzmYxGDs2+OZnmYtYZK3DG8zogt+CbQ+o/iqqtTfpyCmGWulCTEQu5GIbivf7OjgIkH2Nr8SH8UxAGugZNbg==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/promise-spawn": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz", - "integrity": "sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.2.tgz", + "integrity": "sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==", "dev": true, "dependencies": { - "which": "^4.0.0" + "which": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/promise-spawn/node_modules/isexe": { @@ -3603,9 +2912,9 @@ } }, "node_modules/@npmcli/promise-spawn/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -3614,32 +2923,33 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/redact": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-1.1.0.tgz", - "integrity": "sha512-PfnWuOkQgu7gCbnSsAisaX7hKOdZ4wSAhAzH3/ph5dSGau52kCRrMMGbiSQLwyTZpgldkZ49b0brkOr1AzGBHQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.1.1.tgz", + "integrity": "sha512-3Hc2KGIkrvJWJqTbvueXzBeZlmvoOxc2jyX00yzr3+sNFquJg0N8hH4SAPLPVrkWIRQICVpVgjrss971awXVnA==", "dev": true, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/run-script": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.4.tgz", - "integrity": "sha512-9ApYM/3+rBt9V80aYg6tZfzj3UWdiYyCt7gJUD1VJKvWF5nwKDSICXbYIQbspFTq6TOpbsEtIC0LArB8d9PFmg==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.1.0.tgz", + "integrity": "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg==", "dev": true, "dependencies": { - "@npmcli/node-gyp": "^3.0.0", - "@npmcli/package-json": "^5.0.0", - "@npmcli/promise-spawn": "^7.0.0", - "node-gyp": "^10.0.0", - "which": "^4.0.0" + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/run-script/node_modules/isexe": { @@ -3652,9 +2962,9 @@ } }, "node_modules/@npmcli/run-script/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -3663,54 +2973,69 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@nrwl/devkit": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nrwl/devkit/-/devkit-18.2.4.tgz", - "integrity": "sha512-dLK8MMb3eEFWlhtI1kNDNbWIT1Xbrgg3eAQ+Ix/N5JDbxJkJhE28WsIJgQb1NTwe/N87O5JtOpxz4/TsSLJCsQ==", + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", "dev": true, + "hasInstallScript": true, + "optional": true, "dependencies": { - "@nx/devkit": "18.2.4" - } - }, - "node_modules/@nrwl/tao": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nrwl/tao/-/tao-18.2.4.tgz", - "integrity": "sha512-kgJwZ26F+AzvFXaW5eh1g4HLntPcJ6+EE7JyEvrdRzpw7KxTqWy6Ql7dYys6zGlpP4c3PbsXwdc7tGM3Df2PNg==", - "dev": true, - "dependencies": { - "nx": "18.2.4", - "tslib": "^2.3.0" + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" }, - "bin": { - "tao": "index.js" - } - }, - "node_modules/@nx/devkit": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-18.2.4.tgz", - "integrity": "sha512-Ws3BcA/aeXuwsCQ5e7PYy2H7DswareTOEfgs7izxNyGugpydktVH9DZZTOFNDsc06yzgvyTucDbDQ+JsrJ9PcQ==", - "dev": true, - "dependencies": { - "@nrwl/devkit": "18.2.4", - "ejs": "^3.1.7", - "enquirer": "~2.3.6", - "ignore": "^5.0.4", - "semver": "^7.5.3", - "tmp": "~0.2.1", - "tslib": "^2.3.0", - "yargs-parser": "21.1.1" + "engines": { + "node": ">= 10.0.0" }, - "peerDependencies": { - "nx": ">= 16 <= 18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" } }, - "node_modules/@nx/nx-darwin-arm64": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-18.2.4.tgz", - "integrity": "sha512-RYhMImghdyHmwnbNoR2CkLz4Opj9EmuHY3lMfsorg+T4wIOql/iXACrqjnreN7Hy9myJDo1EIbYZ4x8VSxFWtA==", + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", "cpu": [ "arm64" ], @@ -3720,13 +3045,17 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-darwin-x64": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-18.2.4.tgz", - "integrity": "sha512-2mXMslSRD/ZoI/oaX+0Mh9J/hucXtNgdwC4YFbp1u8UKquAaQ6hf4uo0s4i+AfLX0F7roMtkFPaG/+MQUJE1Rw==", + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", "cpu": [ "x64" ], @@ -3736,13 +3065,17 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-freebsd-x64": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-18.2.4.tgz", - "integrity": "sha512-QUiYLvyUT0PS7D8erf49xa1Jyw4Gfev5gtYfME34Twmn/JPx/99ZkBG4wHbzLqRGwlO5K6m6P4qs30Pzfwtw7A==", + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", "cpu": [ "x64" ], @@ -3752,13 +3085,17 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-18.2.4.tgz", - "integrity": "sha512-+fjFciSUhvDV8dPa97Brwb83k3Xa4gHPI2Un8wlpp28Cv4horeGruRZrrifR1VmD2wp2UBIMl5n7YsDP8KvYhQ==", + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", "cpu": [ "arm" ], @@ -3768,13 +3105,37 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-18.2.4.tgz", - "integrity": "sha512-lfaTc+AvV56Uv5mXROiRwh2REiI/7IsqeRDfL+prcuuvJ5Oxi2wYVgnmqcHL+ryQnk0Qn7/d+j/BmYHX5Ve5jQ==", + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", "cpu": [ "arm64" ], @@ -3784,13 +3145,17 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-linux-arm64-musl": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-18.2.4.tgz", - "integrity": "sha512-U6eoLTQmbxUWU9kZxx6hsYN4zmmOrsDDeW+i3aj5aeahfYlmyz6TsT0V3FSB70WGJC5aMVgEi4RkntQMKkm5vQ==", + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", "cpu": [ "arm64" ], @@ -3800,13 +3165,17 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-linux-x64-gnu": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-18.2.4.tgz", - "integrity": "sha512-q8WcJhmcRNORkKjax6WcUwMJe/1mQs+RYlUkGqmi7tD7lfcLSqdLPJVjqVmQAwmy1Wh/MHPsbqRwSerUnCxB1A==", + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", "cpu": [ "x64" ], @@ -3816,13 +3185,17 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-linux-x64-musl": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-18.2.4.tgz", - "integrity": "sha512-0MDuoPgHa6kkBrjg7hwZ2qQivhJbh3lk7r3q4osDrqZcGxq5XVJqeAmYFyChQy4dbQfUm4hhYkEfzpU8M2lnvQ==", + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", "cpu": [ "x64" ], @@ -3832,13 +3205,17 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-18.2.4.tgz", - "integrity": "sha512-uLhSRtfnXzN000Qf27GOjEPXzd4/jBWqv2x419IMh+AEtKHuCEpQNBUAyLvBbQ79SMr+FmCXHB8AeeJ7bEUiRw==", + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", "cpu": [ "arm64" ], @@ -3848,13 +3225,37 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-win32-x64-msvc": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-18.2.4.tgz", - "integrity": "sha512-Y52Afz02Ub1kRZXd6NUTwPMjKQqBKZ35e5dUEpl14na2fWvdgdMz4bYOBPUcmQrovlxBGhmFXtFzxkdW3zyRbQ==", + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", "cpu": [ "x64" ], @@ -3864,9 +3265,33 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "optional": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3892,9 +3317,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.2.tgz", - "integrity": "sha512-ahxSgCkAEk+P/AVO0vYr7DxOD3CwAQrT0Go9BJyGQ9Ef0QxVOfjDZMiF4Y2s3mLyPrjonchIMH/tbWHucJMykQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", + "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", "cpu": [ "arm" ], @@ -3905,9 +3330,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.2.tgz", - "integrity": "sha512-lAarIdxZWbFSHFSDao9+I/F5jDaKyCqAPMq5HqnfpBw8dKDiCaaqM0lq5h1pQTLeIqueeay4PieGR5jGZMWprw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", + "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", "cpu": [ "arm64" ], @@ -3918,9 +3343,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.2.tgz", - "integrity": "sha512-SWsr8zEUk82KSqquIMgZEg2GE5mCSfr9sE/thDROkX6pb3QQWPp8Vw8zOq2GyxZ2t0XoSIUlvHDkrf5Gmf7x3Q==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", + "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", "cpu": [ "arm64" ], @@ -3931,9 +3356,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.2.tgz", - "integrity": "sha512-o/HAIrQq0jIxJAhgtIvV5FWviYK4WB0WwV91SLUnsliw1lSAoLsmgEEgRWzDguAFeUEUUoIWXiJrPqU7vGiVkA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", + "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", "cpu": [ "x64" ], @@ -3943,10 +3368,49 @@ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", + "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", + "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.2.tgz", - "integrity": "sha512-nwlJ65UY9eGq91cBi6VyDfArUJSKOYt5dJQBq8xyLhvS23qO+4Nr/RreibFHjP6t+5ap2ohZrUJcHv5zk5ju/g==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", + "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", + "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", "cpu": [ "arm" ], @@ -3957,9 +3421,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.2.tgz", - "integrity": "sha512-Pg5TxxO2IVlMj79+c/9G0LREC9SY3HM+pfAwX7zj5/cAuwrbfj2Wv9JbMHIdPCfQpYsI4g9mE+2Bw/3aeSs2rQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", + "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", "cpu": [ "arm64" ], @@ -3970,9 +3434,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.2.tgz", - "integrity": "sha512-cAOTjGNm84gc6tS02D1EXtG7tDRsVSDTBVXOLbj31DkwfZwgTPYZ6aafSU7rD/4R2a34JOwlF9fQayuTSkoclA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", + "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", "cpu": [ "arm64" ], @@ -3982,10 +3446,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", + "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.2.tgz", - "integrity": "sha512-4RyT6v1kXb7C0fn6zV33rvaX05P0zHoNzaXI/5oFHklfKm602j+N4mn2YvoezQViRLPnxP8M1NaY4s/5kXO5cw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", + "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", "cpu": [ "ppc64" ], @@ -3996,9 +3473,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.2.tgz", - "integrity": "sha512-KNUH6jC/vRGAKSorySTyc/yRYlCwN/5pnMjXylfBniwtJx5O7X17KG/0efj8XM3TZU7raYRXJFFReOzNmL1n1w==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", + "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", "cpu": [ "riscv64" ], @@ -4009,9 +3486,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.2.tgz", - "integrity": "sha512-xPV4y73IBEXToNPa3h5lbgXOi/v0NcvKxU0xejiFw6DtIYQqOTMhZ2DN18/HrrP0PmiL3rGtRG9gz1QE8vFKXQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", + "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", "cpu": [ "s390x" ], @@ -4022,9 +3499,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.2.tgz", - "integrity": "sha512-QBhtr07iFGmF9egrPOWyO5wciwgtzKkYPNLVCFZTmr4TWmY0oY2Dm/bmhHjKRwZoGiaKdNcKhFtUMBKvlchH+Q==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", + "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", "cpu": [ "x64" ], @@ -4035,9 +3512,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.2.tgz", - "integrity": "sha512-8zfsQRQGH23O6qazZSFY5jP5gt4cFvRuKTpuBsC1ZnSWxV8ZKQpPqOZIUtdfMOugCcBvFGRa1pDC/tkf19EgBw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", + "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", "cpu": [ "x64" ], @@ -4048,9 +3525,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.2.tgz", - "integrity": "sha512-H4s8UjgkPnlChl6JF5empNvFHp77Jx+Wfy2EtmYPe9G22XV+PMuCinZVHurNe8ggtwoaohxARJZbaH/3xjB/FA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", + "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", "cpu": [ "arm64" ], @@ -4061,9 +3538,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.2.tgz", - "integrity": "sha512-djqpAjm/i8erWYF0K6UY4kRO3X5+T4TypIqw60Q8MTqSBaQNpNXDhxdjpZ3ikgb+wn99svA7jxcXpiyg9MUsdw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", + "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", "cpu": [ "ia32" ], @@ -4074,9 +3551,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.2.tgz", - "integrity": "sha512-teAqzLT0yTYZa8ZP7zhFKEx4cotS8Tkk5XiqNMJhD4CpaWB1BHARE4Qy+RzwnXvSAYv+Q3jAqCVBS+PS+Yee8Q==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", + "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", "cpu": [ "x64" ], @@ -4087,129 +3564,139 @@ ] }, "node_modules/@schematics/angular": { - "version": "17.3.4", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.3.4.tgz", - "integrity": "sha512-Rqhp5l76Ej6BOZCHPrvHlA2SBkjv1aHFWAfW9gREke826j46D+fuA0eDAdgeVTz0Fx9e7XM3LdtWsz7CBlV4Ug==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.6.tgz", + "integrity": "sha512-fmbF9ONmEZqxHocCwOSWG2mHp4a22d1uW+DZUBUgZSBUFIrnFw42deOxDq8mkZOZ1Tc73UpLN2GKI7iJeUqS2A==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.3.4", - "@angular-devkit/schematics": "17.3.4", - "jsonc-parser": "3.2.1" + "@angular-devkit/core": "19.2.6", + "@angular-devkit/schematics": "19.2.6", + "jsonc-parser": "3.3.1" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, + "node_modules/@siemens/ngx-datatable": { + "version": "22.4.1", + "resolved": "https://registry.npmjs.org/@siemens/ngx-datatable/-/ngx-datatable-22.4.1.tgz", + "integrity": "sha512-Z19zaxu7tpwMHWc1h5Om9/sZJ39MWTQypju6T6WH7QIkelKgZE7DbYk3siD41vkR/62vT+q0Z1voC2OyxgRX9g==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=17.0.0", + "@angular/core": ">=17.0.0", + "@angular/platform-browser": ">=17.0.0", + "rxjs": "^7.8.0" + } + }, "node_modules/@sigstore/bundle": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.3.1.tgz", - "integrity": "sha512-eqV17lO3EIFqCWK3969Rz+J8MYrRZKw9IBHpSo6DEcEX2c+uzDFOgHE9f2MnyDpfs48LFO4hXmk9KhQ74JzU1g==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", + "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", "dev": true, "dependencies": { - "@sigstore/protobuf-specs": "^0.3.1" + "@sigstore/protobuf-specs": "^0.4.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-1.1.0.tgz", - "integrity": "sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", + "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", "dev": true, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.1.tgz", - "integrity": "sha512-aIL8Z9NsMr3C64jyQzE0XlkEyBLpgEJJFDHLVVStkFV5Q3Il/r/YtY6NJWKQ4cy4AE7spP1IX5Jq7VCAxHHMfQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.0.tgz", + "integrity": "sha512-o09cLSIq9EKyRXwryWDOJagkml9XgQCoCSRjHOnHLnvsivaW7Qznzz6yjfV7PHJHhIvyp8OH7OX8w0Dc5bQK7A==", "dev": true, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/sign": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.3.0.tgz", - "integrity": "sha512-tsAyV6FC3R3pHmKS880IXcDJuiFJiKITO1jxR1qbplcsBkZLBmjrEw5GbC7ikD6f5RU1hr7WnmxB/2kKc1qUWQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", + "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.3.0", - "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.1", - "make-fetch-happen": "^13.0.0" + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/tuf": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.2.tgz", - "integrity": "sha512-mwbY1VrEGU4CO55t+Kl6I7WZzIl+ysSzEYdA1Nv/FTrl2bkeaPXo5PnWZAVfcY2zSdhOpsUTJW67/M2zHXGn5w==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.0.tgz", + "integrity": "sha512-suVMQEA+sKdOz5hwP9qNcEjX6B45R+hFFr4LAWzbRc5O+U2IInwvay/bpG5a4s+qR35P/JK/PiKiRGjfuLy1IA==", "dev": true, "dependencies": { - "@sigstore/protobuf-specs": "^0.3.0", - "tuf-js": "^2.2.0" + "@sigstore/protobuf-specs": "^0.4.0", + "tuf-js": "^3.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/verify": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-1.2.0.tgz", - "integrity": "sha512-hQF60nc9yab+Csi4AyoAmilGNfpXT+EXdBgFkP9OgPwIBPwyqVf7JAWPtmqrrrneTmAT6ojv7OlH1f6Ix5BG4Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.0.tgz", + "integrity": "sha512-kAAM06ca4CzhvjIZdONAL9+MLppW3K48wOFy1TbuaWFW/OMfl8JuTgW0Bm02JB1WJGT/ET2eqav0KTEKmxqkIA==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.3.1", - "@sigstore/core": "^1.1.0", - "@sigstore/protobuf-specs": "^0.3.1" + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true - }, "node_modules/@swimlane/ngx-charts": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-20.5.0.tgz", - "integrity": "sha512-PNBIHdu/R3ceD7jnw1uCBVOj4k3T6IxfdW6xsDsglGkZyoWMEEq4tLoEurjLEKzmDtRv9c35kVNOXy0lkOuXeA==", + "version": "22.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-22.0.0-alpha.0.tgz", + "integrity": "sha512-sauI4QcfpuKXmRWajpeVtAoT7z8uI3u1+hvfcsJ796LRr06C676dkjoZsk7aX3EU+6uF8mJpXClOT/JcfnZrEA==", "dependencies": { - "d3-array": "^3.1.1", + "d3-array": "^3.2.0", "d3-brush": "^3.0.0", "d3-color": "^3.1.0", "d3-ease": "^3.0.1", "d3-format": "^3.1.0", - "d3-hierarchy": "^3.1.0", + "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-shape": "^3.2.0", - "d3-time-format": "^3.0.0", + "d3-time-format": "^4.1.0", "d3-transition": "^3.0.1", - "rfdc": "^1.3.0", - "tslib": "^2.0.0" + "gradient-path": "^2.3.0", + "tslib": "^2.3.1" }, "peerDependencies": { - "@angular/animations": ">=12.0.0", - "@angular/cdk": ">=12.0.0", - "@angular/common": ">=12.0.0", - "@angular/core": ">=12.0.0", - "@angular/forms": ">=12.0.0", - "@angular/platform-browser": ">=12.0.0", - "@angular/platform-browser-dynamic": ">=12.0.0", - "rxjs": "^6.5.3 || ^7.4.0" + "@angular/animations": "17.x || 18.x || 19.x", + "@angular/cdk": "17.x || 18.x || 19.x", + "@angular/common": "17.x || 18.x || 19.x", + "@angular/core": "17.x || 18.x || 19.x", + "@angular/forms": "17.x || 18.x || 19.x", + "@angular/platform-browser": "17.x || 18.x || 19.x", + "@angular/platform-browser-dynamic": "17.x || 18.x || 19.x", + "rxjs": "7.x" } }, "node_modules/@tsconfig/node10": { @@ -4246,22 +3733,22 @@ } }, "node_modules/@tufjs/models": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.0.tgz", - "integrity": "sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", + "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", "dev": true, "dependencies": { "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.3" + "minimatch": "^9.0.5" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@tweenjs/tween.js": { - "version": "23.1.1", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.1.tgz", - "integrity": "sha512-ZpboH7pCPPeyBWKf8c7TJswtCEQObFo3bOBYalm99NzZarATALYCo5OhbCa/n4RQyJyHfhkdx+hNrdL5ByFYDw==" + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4300,44 +3787,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "dev": true, - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -4591,56 +4040,12 @@ "@types/d3-selection": "*" } }, - "node_modules/@types/eslint": { - "version": "8.56.9", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.9.tgz", - "integrity": "sha512-W4W3KcqzjJ0sHg2vAq9vfml6OhsJ53TcUjUqfzzZf/EChUtwspszj/S0pzMxnfRcO55/iGq47dscXw71Fxc4Zg==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", - "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", - "dev": true, - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, "node_modules/@types/file-saver": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", @@ -4653,21 +4058,6 @@ "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", "dev": true }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true - }, - "node_modules/@types/http-proxy": { - "version": "1.17.14", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", - "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4675,233 +4065,89 @@ "dev": true }, "node_modules/@types/luxon": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", - "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", - "dev": true - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz", + "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==", "dev": true }, "node_modules/@types/node": { - "version": "20.12.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", - "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "version": "22.13.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz", + "integrity": "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ==", "dev": true, "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.20.0" } }, - "node_modules/@types/node-forge": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", - "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/qs": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", - "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==", - "dev": true - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "dev": true - }, - "node_modules/@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "dev": true - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "dev": true, - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/ws": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", - "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", - "dev": true, - "dependencies": { - "@types/node": "*" - } + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz", - "integrity": "sha512-gKmTNwZnblUdnTIJu3e9kmeRRzV2j1a/LUO27KNNAnIC5zjy1aSvXSRp4rVNlmAoHlQ7HzX42NbKpcSr4jF80A==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.28.0.tgz", + "integrity": "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.6.0", - "@typescript-eslint/type-utils": "7.6.0", - "@typescript-eslint/utils": "7.6.0", - "@typescript-eslint/visitor-keys": "7.6.0", - "debug": "^4.3.4", + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/type-utils": "8.28.0", + "@typescript-eslint/utils": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.6.0.tgz", - "integrity": "sha512-NxAfqAPNLG6LTmy7uZgpK8KcuiS2NZD/HlThPXQRGwz6u7MDBWRVliEEl1Gj6U7++kVJTpehkhZzCJLMK66Scw==", - "dev": true, - "dependencies": { - "@typescript-eslint/typescript-estree": "7.6.0", - "@typescript-eslint/utils": "7.6.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.6.0.tgz", - "integrity": "sha512-x54gaSsRRI+Nwz59TXpCsr6harB98qjXYzsRxGqvA5Ue3kQH+FxS7FYU81g/omn22ML2pZJkisy6Q+ElK8pBCA==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.15", - "@types/semver": "^7.5.8", - "@typescript-eslint/scope-manager": "7.6.0", - "@typescript-eslint/types": "7.6.0", - "@typescript-eslint/typescript-estree": "7.6.0", - "semver": "^7.6.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.6.0.tgz", - "integrity": "sha512-usPMPHcwX3ZoPWnBnhhorc14NJw9J4HpSXQX4urF2TPKG0au0XhJoZyX62fmvdHONUkmyUe74Hzm1//XA+BoYg==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.28.0.tgz", + "integrity": "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.6.0", - "@typescript-eslint/types": "7.6.0", - "@typescript-eslint/typescript-estree": "7.6.0", - "@typescript-eslint/visitor-keys": "7.6.0", + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/typescript-estree": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.6.0.tgz", - "integrity": "sha512-ngttyfExA5PsHSx0rdFgnADMYQi+Zkeiv4/ZxGYUWd0nLs63Ha0ksmp8VMxAIC0wtCFxMos7Lt3PszJssG/E6w==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.28.0.tgz", + "integrity": "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.6.0", - "@typescript-eslint/visitor-keys": "7.6.0" + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -4909,112 +4155,35 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.2.0.tgz", - "integrity": "sha512-xHi51adBHo9O9330J8GQYQwrKBqbIPJGZZVQTHHmy200hvkLZFWJIFtAG/7IYTWUyun6DE6w5InDReePJYJlJA==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.28.0.tgz", + "integrity": "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.2.0", - "@typescript-eslint/utils": "7.2.0", + "@typescript-eslint/typescript-estree": "8.28.0", + "@typescript-eslint/utils": "8.28.0", "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", - "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", - "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", - "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/types": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.6.0.tgz", - "integrity": "sha512-h02rYQn8J+MureCvHVVzhl69/GAfQGPQZmOMjG1KfCl7o3HtMSlPaPUAPu6lLctXI5ySRGIYk94clD/AUMCUgQ==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.28.0.tgz", + "integrity": "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA==", "dev": true, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -5022,385 +4191,108 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.6.0.tgz", - "integrity": "sha512-+7Y/GP9VuYibecrCQWSKgl3GvUM5cILRttpWtnAu8GNL9j11e4tbuGZmZjJ8ejnKYyBRb2ddGQ3rEFCq3QjMJw==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.28.0.tgz", + "integrity": "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.6.0", - "@typescript-eslint/visitor-keys": "7.6.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.2.0.tgz", - "integrity": "sha512-YfHpnMAGb1Eekpm3XRK8hcMwGLGsnT6L+7b2XyRv6ouDuJU1tZir1GS2i0+VXRatMwSI1/UfcyPe53ADkU+IuA==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.28.0.tgz", + "integrity": "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.2.0", - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/typescript-estree": "7.2.0", - "semver": "^7.5.4" + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/typescript-estree": "8.28.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.56.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", - "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", - "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", - "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", - "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.6.0.tgz", - "integrity": "sha512-4eLB7t+LlNUmXzfOu1VAIAdkjbu5xNSerURS9X/S5TUKWFRpXRQZbmtPqgKmYx8bj3J0irtQXSiWAOY82v+cgw==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.28.0.tgz", + "integrity": "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.6.0", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.28.0", + "eslint-visitor-keys": "^4.2.0" }, "engines": { - "node": "^18.18.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "node_modules/@vitejs/plugin-basic-ssl": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz", - "integrity": "sha512-wO4Dk/rm8u7RNhOf95ZzcEmC9rYOncYgvq4z3duaJrCgjN8BxAnDVyndanfcJZ0O6XZzHz6Q0hTimxTg8Y9g/A==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, "engines": { - "node": ">=14.6.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz", + "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==", + "dev": true, + "engines": { + "node": ">=14.21.3" }, "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", - "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", - "dev": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", - "dev": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", - "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.12.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", - "dev": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", - "dev": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", - "dev": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", - "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-opt": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1", - "@webassemblyjs/wast-printer": "1.12.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", - "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", - "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", - "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", - "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true - }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "dev": true }, - "node_modules/@yarnpkg/parsers": { - "version": "3.0.0-rc.46", - "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.0-rc.46.tgz", - "integrity": "sha512-aiATs7pSutzda/rq8fnuPwTglyVwjM22bNnK2ZgjrpAjQHSSl3lztd2f9evst1W/qnC58DRz7T7QndUDumAR4Q==", - "dev": true, - "dependencies": { - "js-yaml": "^3.10.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.15.0" - } - }, - "node_modules/@zkochan/js-yaml": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.6.tgz", - "integrity": "sha512-nzvgl3VfhcELQ8LyVrYOru+UtAy1nrygk2+AGbTm8a5YcO6o8lSjAT+pfg3vJWxIoZKOUhrK6UU7xW/+00kQrg==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@zkochan/js-yaml/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz", + "integrity": "sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/abort-controller": { @@ -5414,23 +4306,10 @@ "node": ">=6.5" } }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -5439,15 +4318,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "dev": true, - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -5466,68 +4336,25 @@ "node": ">=0.4.0" } }, - "node_modules/adjust-sourcemap-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", - "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", - "dev": true, - "dependencies": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - }, - "engines": { - "node": ">=8.9" - } - }, - "node_modules/adjust-sourcemap-loader/node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, "node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "dev": true, - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -5535,9 +4362,9 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dev": true, "dependencies": { "ajv": "^8.0.0" @@ -5551,27 +4378,6 @@ } } }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -5587,18 +4393,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "dev": true, - "engines": [ - "node >= 0.8.0" - ], - "bin": { - "ansi-html": "bin/ansi-html" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -5608,37 +4402,17 @@ } }, "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dependencies": { - "color-convert": "^1.9.0" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=4" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "engines": { - "node": ">=8.6" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/arg": { @@ -5648,218 +4422,26 @@ "dev": true }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "engines": { - "node": ">=8" - } - }, - "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "dev": true - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, - "node_modules/autoprefixer": { - "version": "10.4.18", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz", - "integrity": "sha512-1DKbDfsr6KUElM6wg+0zRNkB/Q7WcKYAaK+pzXn+Xqmszm/5Xa9coeNdtP88Vi+dPzZnMjhge8GIV49ZQkDa+g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001591", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", - "dev": true, - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "node": ">= 0.4" } }, "node_modules/axobject-query": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", - "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/babel-loader": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.3.tgz", - "integrity": "sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw==", - "dev": true, - "dependencies": { - "find-cache-dir": "^4.0.0", - "schema-utils": "^4.0.0" - }, "engines": { - "node": ">= 14.15.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0", - "webpack": ">=5" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.10", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz", - "integrity": "sha512-rpIuu//y5OX6jVU+a5BCn1R5RSZYWAl2Nar76iwaOdycqb6JPxediskWFMMl7stfwNJR4b7eiQvh5fB5TEQJTQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.1", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.9.0.tgz", - "integrity": "sha512-7nZPG1uzK2Ymhy/NbaOWTg3uibM2BmGASS4vHS4szRZAIR8R6GwA/xAujpdrXU5iyklrimWnLWU+BLF9suPTqg==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.5.0", - "core-js-compat": "^3.34.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", - "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.5.tgz", - "integrity": "sha512-OJGYZlhLqBh2DDHeqAxWB1XIvr49CxiJ2gIt61/PU55CQK4Z58OzMqjDe1zwQdQk+rBYsRc+1rJmdajM3gimHg==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.5.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", - "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "node": ">= 0.4" } }, "node_modules/balanced-match": { @@ -5886,30 +4468,23 @@ } ] }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "dev": true - }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "node_modules/beasties": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.2.0.tgz", + "integrity": "sha512-Ljqskqx/tbZagIglYoJIMzH5zgssyp+in9+9sAyh15N22AornBeIDnb8EZ6Rk+6ShfMxd92uO3gfpT0NtZbpow==", "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "engines": { - "node": ">=8" + "dependencies": { + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^9.1.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=14.0.0" } }, "node_modules/bl": { @@ -5922,64 +4497,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/bonjour-service": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz", - "integrity": "sha512-oSzCS2zV14bh2kji6vNe7vrpJYCHGvcZnlffFQ1MEoX/WOeQ/teD8SYWKR942OI3INjq8OMNJlbPK5LLLUxFDw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -6013,20 +4530,20 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" } }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", "funding": [ { "type": "opencollective", @@ -6042,10 +4559,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -6083,31 +4600,13 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/builtins": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.1.0.tgz", - "integrity": "sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==", - "dev": true, - "dependencies": { - "semver": "^7.0.0" - } - }, - "node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cacache": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz", - "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==", + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", "dev": true, "dependencies": { - "@npmcli/fs": "^3.1.0", + "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", @@ -6115,41 +4614,69 @@ "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "engines": { + "node": ">=18" } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "node_modules/cacache/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "bin": { + "mkdirp": "dist/cjs/src/bin.js" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "engines": { + "node": ">=18" } }, "node_modules/callsites": { @@ -6160,19 +4687,10 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { - "version": "1.0.30001609", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001609.tgz", - "integrity": "sha512-JFPQs34lHKx1B5t1EpQpWH4c+29zIyn/haGsbpfq3suuV9v56enjFt23zqijxGTMwy1p/4H2tjnQMY+p1WoAyA==", + "version": "1.0.30001707", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz", + "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==", "funding": [ { "type": "opencollective", @@ -6189,16 +4707,18 @@ ] }, "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/chardet": { @@ -6212,29 +4732,6 @@ "resolved": "https://registry.npmjs.org/charts.css/-/charts.css-1.1.0.tgz", "integrity": "sha512-K1Qyb8ZKsu5cDrVbZeHECk/xSq6iOl8IDTR35uaMdhr/Vyyxvg9nYQy3KNB3aidxJ2E251afX5q2725N0uL3Vw==" }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -6244,24 +4741,6 @@ "node": ">=10" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", - "dev": true, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -6284,6 +4763,72 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -6306,36 +4851,6 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/cliui/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -6360,32 +4875,21 @@ "node": ">=0.8" } }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" + "color-name": "~1.1.4" }, "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" + "node": ">=7.0.0" } }, "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/colorette": { "version": "2.0.20", @@ -6393,233 +4897,16 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "dev": true - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", - "dev": true, - "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", - "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/compression/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "dev": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "dev": true - }, - "node_modules/copy-anything": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", - "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", - "dev": true, - "dependencies": { - "is-what": "^3.14.1" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/copy-webpack-plugin": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", - "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", - "dev": true, - "dependencies": { - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.1", - "globby": "^13.1.1", - "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/globby": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", - "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", - "dev": true, - "dependencies": { - "dir-glob": "^3.0.1", - "fast-glob": "^3.3.0", - "ignore": "^5.2.4", - "merge2": "^1.4.1", - "slash": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/copy-webpack-plugin/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/core-js-compat": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.36.1.tgz", - "integrity": "sha512-Dk997v9ZCt3X/npqzyGdTlq6t7lDBhZwGvV94PKzDArjp7BTRm7WlDAXYd/OWdeFHO8OChQYRJNJvUCqCbrtKA==", - "dev": true, - "dependencies": { - "browserslist": "^4.23.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, "node_modules/cosmiconfig": { @@ -6647,117 +4934,16 @@ } } }, - "node_modules/cosmiconfig/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, - "node_modules/critters": { - "version": "0.0.22", - "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.22.tgz", - "integrity": "sha512-NU7DEcQZM2Dy8XTKFHxtdnIM/drE312j2T4PCVaSUcS0oBeyT/NImpRw/Ap0zOr/1SE7SgPK9tGPg1WK/sVakw==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "css-select": "^5.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.2", - "htmlparser2": "^8.0.2", - "postcss": "^8.4.23", - "postcss-media-query-parser": "^0.2.3" - } - }, - "node_modules/critters/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/critters/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/critters/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/critters/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/critters/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/critters/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -6767,41 +4953,6 @@ "node": ">= 8" } }, - "node_modules/css-loader": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.10.0.tgz", - "integrity": "sha512-LTSA/jWbwdMlk+rhmElbDR2vbtQoTBPr7fkJE+mxrHj+7ru0hUmHafDRzWIjIHTwpitWVaqY2/UWGRca3yUgRw==", - "dev": true, - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.33", - "postcss-modules-extract-imports": "^3.0.0", - "postcss-modules-local-by-default": "^4.0.4", - "postcss-modules-scope": "^3.1.1", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -6830,18 +4981,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -7020,34 +5159,16 @@ } }, "node_modules/d3-time-format": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", - "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", "dependencies": { - "d3-time": "1 - 2" + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" } }, - "node_modules/d3-time-format/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/d3-time-format/node_modules/d3-time": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", - "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", - "dependencies": { - "d3-array": "2" - } - }, - "node_modules/d3-time-format/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" - }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -7081,11 +5202,11 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -7102,18 +5223,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/default-gateway": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", - "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", - "dev": true, - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -7125,79 +5234,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-it": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/detect-it/-/detect-it-4.0.1.tgz", "integrity": "sha512-dg5YBTJYvogK1+dA2mBUDKzOWfYZtHVba89SyZUhc4+e3i2tzgjANFg5lDRCd3UOtRcw00vUTMK8LELcMdicug==" }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "optional": true, + "engines": { + "node": ">=8" + } }, "node_modules/detect-passive-events": { "version": "2.0.3", @@ -7216,51 +5266,6 @@ "node": ">=0.3.1" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dev": true, - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7311,9 +5316,9 @@ } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, "dependencies": { "dom-serializer": "^2.0.0", @@ -7324,27 +5329,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/dotenv": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.2.tgz", - "integrity": "sha512-HTlk5nmhkm8F6JcdXvHIzaorzCoziNQT9mGxLPVXW8wJF1TiGSL60ZGB4gHWabHOaMmWmhvk2/lPHfnBiT78AQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/motdotla/dotenv?sponsor=1" - } - }, - "node_modules/dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -7356,59 +5340,21 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true - }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dev": true, - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/electron-to-chromium": { - "version": "1.4.736", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.736.tgz", - "integrity": "sha512-Rer6wc3ynLelKNM4lOCg7/zPQj8tPOCB2hzD32PX9wd3hgRRi9MxEbmkFCokzcEhRVMiOVLjnL9ig9cefJ+6+Q==" + "version": "1.5.46", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.46.tgz", + "integrity": "sha512-1XDk0Z8/YRgB2t5GeEg8DPK592DLjVmd/5uwAu6c/S4Z0CUwV/RwYqe5GWxQqcoN3bJ5U7hYMiMRPZzpCzSBhQ==" }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -7418,6 +5364,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7426,45 +5373,10 @@ "node": ">=0.10.0" } }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.16.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz", - "integrity": "sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/enquirer": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", - "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "dev": true, - "dependencies": { - "ansi-colors": "^4.1.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "devOptional": true, "engines": { "node": ">=0.12" }, @@ -7481,25 +5393,24 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "dev": true }, - "node_modules/errno": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", - "dev": true, - "optional": true, - "dependencies": { - "prr": "~1.0.1" - }, - "bin": { - "errno": "cli.js" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -7508,165 +5419,118 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", - "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", - "dev": true - }, "node_modules/esbuild": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.1.tgz", - "integrity": "sha512-OJwEgrpWm/PCMsLVWXKqvcjme3bHNpOgN7Tb6cQnR5n0TPbQx1/Xrn7rqM+wn17bYeT6MGB5sn1Bh5YiGi70nA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", + "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", "dev": true, "hasInstallScript": true, - "optional": true, "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.1", - "@esbuild/android-arm": "0.20.1", - "@esbuild/android-arm64": "0.20.1", - "@esbuild/android-x64": "0.20.1", - "@esbuild/darwin-arm64": "0.20.1", - "@esbuild/darwin-x64": "0.20.1", - "@esbuild/freebsd-arm64": "0.20.1", - "@esbuild/freebsd-x64": "0.20.1", - "@esbuild/linux-arm": "0.20.1", - "@esbuild/linux-arm64": "0.20.1", - "@esbuild/linux-ia32": "0.20.1", - "@esbuild/linux-loong64": "0.20.1", - "@esbuild/linux-mips64el": "0.20.1", - "@esbuild/linux-ppc64": "0.20.1", - "@esbuild/linux-riscv64": "0.20.1", - "@esbuild/linux-s390x": "0.20.1", - "@esbuild/linux-x64": "0.20.1", - "@esbuild/netbsd-x64": "0.20.1", - "@esbuild/openbsd-x64": "0.20.1", - "@esbuild/sunos-x64": "0.20.1", - "@esbuild/win32-arm64": "0.20.1", - "@esbuild/win32-ia32": "0.20.1", - "@esbuild/win32-x64": "0.20.1" - } - }, - "node_modules/esbuild-wasm": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.20.1.tgz", - "integrity": "sha512-6v/WJubRsjxBbQdz6izgvx7LsVFvVaGmSdwrFHmEzoVgfXL89hkKPoQHsnVI2ngOkcBUQT9kmAM1hVL1k/Av4A==", - "dev": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" + "@esbuild/aix-ppc64": "0.25.1", + "@esbuild/android-arm": "0.25.1", + "@esbuild/android-arm64": "0.25.1", + "@esbuild/android-x64": "0.25.1", + "@esbuild/darwin-arm64": "0.25.1", + "@esbuild/darwin-x64": "0.25.1", + "@esbuild/freebsd-arm64": "0.25.1", + "@esbuild/freebsd-x64": "0.25.1", + "@esbuild/linux-arm": "0.25.1", + "@esbuild/linux-arm64": "0.25.1", + "@esbuild/linux-ia32": "0.25.1", + "@esbuild/linux-loong64": "0.25.1", + "@esbuild/linux-mips64el": "0.25.1", + "@esbuild/linux-ppc64": "0.25.1", + "@esbuild/linux-riscv64": "0.25.1", + "@esbuild/linux-s390x": "0.25.1", + "@esbuild/linux-x64": "0.25.1", + "@esbuild/netbsd-arm64": "0.25.1", + "@esbuild/netbsd-x64": "0.25.1", + "@esbuild/openbsd-arm64": "0.25.1", + "@esbuild/openbsd-x64": "0.25.1", + "@esbuild/sunos-x64": "0.25.1", + "@esbuild/win32-arm64": "0.25.1", + "@esbuild/win32-ia32": "0.25.1", + "@esbuild/win32-x64": "0.25.1" } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "engines": { "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "9.23.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", + "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.2", + "@eslint/config-helpers": "^0.2.0", + "@eslint/core": "^0.12.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.23.0", + "@eslint/plugin-kit": "^0.2.7", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-scope": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.1.tgz", - "integrity": "sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", @@ -7707,27 +5571,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -7738,40 +5581,6 @@ "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -7784,17 +5593,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7828,42 +5633,6 @@ "node": ">=10.13.0" } }, - "node_modules/eslint/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -7927,58 +5696,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", "dev": true, "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, "engines": { - "node": ">=4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { @@ -8023,15 +5767,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -8041,20 +5776,11 @@ } }, "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -8063,96 +5789,10 @@ "node": ">=12.0.0" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, "node_modules/exponential-backoff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", - "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", - "dev": true - }, - "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "dev": true, - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", + "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", "dev": true }, "node_modules/external-editor": { @@ -8169,18 +5809,6 @@ "node": ">=4" } }, - "node_modules/external-editor/node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8188,15 +5816,15 @@ "dev": true }, "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -8214,6 +5842,12 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "dev": true + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -8222,18 +5856,6 @@ "reusify": "^1.0.4" } }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dev": true, - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/fetch-cookie": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", @@ -8243,31 +5865,16 @@ "tough-cookie": "^4.0.0" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/file-saver": { @@ -8275,31 +5882,10 @@ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -8307,119 +5893,25 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/find-cache-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", - "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", - "dev": true, - "dependencies": { - "common-path-prefix": "^3.0.0", - "pkg-dir": "^7.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/flat/-/flat-6.0.1.tgz", - "integrity": "sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw==", - "bin": { - "flat": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, - "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", @@ -8435,57 +5927,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true - }, "node_modules/fs-extra": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", @@ -8511,12 +5952,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/fs-monkey": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", - "integrity": "sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==", - "dev": true - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -8526,6 +5961,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -8560,41 +5996,13 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", "dev": true, "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8646,43 +6054,19 @@ "node": ">=4" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/gradient-path": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/gradient-path/-/gradient-path-2.3.0.tgz", + "integrity": "sha512-vZdF/Z0EpqUztzWXFjFC16lqcialHacYoRonslk/bC6CuujkuIrqx7etlzdYHY4SnUU94LRWESamZKfkGh7yYQ==", + "dependencies": { + "tinygradient": "^1.0.0" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -8704,54 +6088,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "dev": true - }, "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "engines": { - "node": ">=4" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, "node_modules/hasown": { @@ -8767,84 +6109,23 @@ } }, "node_modules/hosted-git-info": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz", - "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.0.2.tgz", + "integrity": "sha512-sYKnA7eGln5ov8T8gnYlkSOxFJvywzEx9BueN6xo/GKO8PGiI6uK6xx+DIGe45T3bdVjLAQDQW1aicT8z8JwQg==", "dev": true, "dependencies": { "lru-cache": "^10.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } - }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/html-entities": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", - "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ] - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -8852,9 +6133,9 @@ "dev": true }, "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -8866,8 +6147,8 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" + "domutils": "^3.1.0", + "entities": "^4.5.0" } }, "node_modules/http-cache-semantics": { @@ -8876,48 +6157,6 @@ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", "dev": true }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "dev": true - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", - "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", - "dev": true - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -8931,52 +6170,19 @@ "node": ">= 14" } }, - "node_modules/http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", - "dev": true, - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, "node_modules/https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { "node": ">= 14" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -8989,18 +6195,6 @@ "node": ">=0.10.0" } }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -9021,43 +6215,30 @@ ] }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" } }, "node_modules/ignore-walk": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz", - "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", + "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", "dev": true, "dependencies": { "minimatch": "^9.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", - "dev": true, - "optional": true, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=0.10.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/immutable": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", - "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", "dev": true }, "node_modules/import-fresh": { @@ -9092,15 +6273,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -9116,50 +6288,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", - "integrity": "sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/inquirer": { - "version": "9.2.15", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.15.tgz", - "integrity": "sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==", - "dev": true, - "dependencies": { - "@ljharb/through": "^2.3.12", - "ansi-escapes": "^4.3.2", - "chalk": "^5.3.0", - "cli-cursor": "^3.1.0", - "cli-width": "^4.1.0", - "external-editor": "^3.1.0", - "figures": "^3.2.0", - "lodash": "^4.17.21", - "mute-stream": "1.0.0", - "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/inquirer/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/internmap": { @@ -9183,64 +6317,26 @@ "node": ">= 12" } }, - "node_modules/ip-address/node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true - }, - "node_modules/ipaddr.js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", - "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", - "dev": true, - "engines": { - "node": ">= 10" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9276,12 +6372,6 @@ "node": ">=8" } }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -9290,51 +6380,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -9346,44 +6391,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-what": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", - "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", - "dev": true - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -9432,27 +6444,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-report/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/istanbul-lib-source-maps": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", @@ -9506,270 +6497,17 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jake": { - "version": "10.8.7", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", - "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", - "dev": true, - "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" - }, - "bin": { - "jake": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jake/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jake/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/jake/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jake/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jake/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jake/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jake/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/jake/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-diff/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-diff/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-diff/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-diff/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-diff/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -9782,14 +6520,14 @@ "dev": true }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-buffer": { @@ -9799,12 +6537,12 @@ "dev": true }, "node_modules/json-parse-even-better-errors": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", - "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/json-schema-traverse": { @@ -9831,9 +6569,9 @@ } }, "node_modules/jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true }, "node_modules/jsonfile": { @@ -9905,15 +6643,6 @@ "node": "*" } }, - "node_modules/karma-source-map-support": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", - "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", - "dev": true, - "dependencies": { - "source-map-support": "^0.5.5" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9923,114 +6652,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/klona": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", - "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/launch-editor": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", - "integrity": "sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==", - "dev": true, - "dependencies": { - "picocolors": "^1.0.0", - "shell-quote": "^1.8.1" - } - }, - "node_modules/less": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz", - "integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==", - "dev": true, - "dependencies": { - "copy-anything": "^2.0.1", - "parse-node-version": "^1.0.1", - "tslib": "^2.3.0" - }, - "bin": { - "lessc": "bin/lessc" - }, - "engines": { - "node": ">=6" - }, - "optionalDependencies": { - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "needle": "^3.1.0", - "source-map": "~0.6.0" - } - }, - "node_modules/less-loader": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-11.1.0.tgz", - "integrity": "sha512-C+uDBV7kS7W5fJlUjq5mPBeBVhYpTIm5gB09APT9o3n/ILeaXVsiSFTbZpTJCJwQ/Crczfn3DmfQFwxYusWFug==", - "dev": true, - "dependencies": { - "klona": "^2.0.4" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "less": "^3.5.0 || ^4.0.0", - "webpack": "^5.0.0" - } - }, - "node_modules/less/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "optional": true, - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/less/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "optional": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/less/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10044,78 +6665,127 @@ "node": ">= 0.8.0" } }, - "node_modules/license-webpack-plugin": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", - "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", + "node_modules/listr2": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", + "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", "dev": true, "dependencies": { - "webpack-sources": "^3.0.0" + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-sources": { - "optional": true - } + "engines": { + "node": ">=18.0.0" } }, - "node_modules/lines-and-columns": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", - "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, "engines": { - "node": ">=6.11.5" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/loader-utils": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", - "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", - "dev": true, - "engines": { - "node": ">= 12.13.0" - } + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "dependencies": { - "p-locate": "^4.1.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } }, - "node_modules/lodash.deburr": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-4.1.0.tgz", - "integrity": "sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==" + "node_modules/lmdb": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.2.6.tgz", + "integrity": "sha512-SuHqzPl7mYStna8WRotY8XX/EUZBjjv3QyKIByeCLFfC9uXT/OIHByEcA07PzbMfQAM0KYJtLgtpMRlIe5dErQ==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.2.6", + "@lmdb/lmdb-darwin-x64": "3.2.6", + "@lmdb/lmdb-linux-arm": "3.2.6", + "@lmdb/lmdb-linux-arm64": "3.2.6", + "@lmdb/lmdb-linux-x64": "3.2.6", + "@lmdb/lmdb-win32-x64": "3.2.6" + } }, "node_modules/lodash.kebabcase": { "version": "4.1.1", @@ -10143,68 +6813,194 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, "dependencies": { - "color-convert": "^2.0.1" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "restore-cursor": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, "dependencies": { - "color-name": "~1.1.4" + "get-east-asian-width": "^1.0.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/log-symbols/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "dependencies": { + "mimic-function": "^5.0.0" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/supports-color": { + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "dependencies": { - "has-flag": "^4.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/lru-cache": { @@ -10216,23 +7012,20 @@ } }, "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", "engines": { "node": ">=12" } }, "node_modules/magic-string": { - "version": "0.30.8", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", - "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/make-dir": { @@ -10257,60 +7050,27 @@ "dev": true }, "node_modules/make-fetch-happen": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz", - "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", "dev": true, "dependencies": { - "@npmcli/agent": "^2.0.0", - "cacache": "^18.0.0", + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", - "is-lambda": "^1.0.1", "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", + "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1", - "ssri": "^10.0.0" + "ssri": "^12.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", - "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", - "dev": true, - "dependencies": { - "fs-monkey": "^1.0.4" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", - "dev": true - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10319,21 +7079,12 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -10351,39 +7102,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -10392,36 +7110,22 @@ "node": ">=6" } }, - "node_modules/mini-css-extract-plugin": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.8.1.tgz", - "integrity": "sha512-/1HDlyFRxWIZPI1ZpgqlZ8jMw/1Dp/dl3P0L1jtZ+zVcHqwPhGwaJwKL00WVgfnBy6PWCde9W65or7IIETImuA==", + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, - "dependencies": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" - }, "engines": { - "node": ">= 12.13.0" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true - }, "node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -10432,19 +7136,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "engines": { "node": ">=16 || 14 >=14.17" } @@ -10462,17 +7157,17 @@ } }, "node_modules/minipass-fetch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz", - "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", "dev": true, "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" + "minizlib": "^3.0.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" }, "optionalDependencies": { "encoding": "^0.1.13" @@ -10508,34 +7203,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/minipass-json-stream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz", - "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==", - "dev": true, - "dependencies": { - "jsonparse": "^1.3.1", - "minipass": "^3.0.0" - } - }, - "node_modules/minipass-json-stream/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-json-stream/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", @@ -10597,36 +7264,33 @@ "dev": true }, "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", "dev": true, "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "minipass": "^7.0.4", + "rimraf": "^5.0.5" }, "engines": { - "node": ">= 8" + "node": ">= 18" } }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "node_modules/minizlib/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", "dev": true, "dependencies": { - "yallist": "^4.0.0" + "glob": "^10.3.7" }, - "engines": { - "node": ">=8" + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -10640,45 +7304,64 @@ } }, "node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "engines": { "node": ">=10" } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "node_modules/msgpackr": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz", + "integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==", "dev": true, + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "dev": true, + "hasInstallScript": true, + "optional": true, "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" + "node-gyp-build-optional-packages": "5.2.2" }, "bin": { - "multicast-dns": "cli.js" + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" } }, "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -10699,51 +7382,15 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/needle": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", - "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", - "dev": true, - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" - } - }, - "node_modules/needle/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, "engines": { "node": ">= 0.6" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, "node_modules/ng-circle-progress": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/ng-circle-progress/-/ng-circle-progress-1.7.1.tgz", @@ -10771,23 +7418,23 @@ } }, "node_modules/ng-select2-component": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-14.0.1.tgz", - "integrity": "sha512-eynfQXr2rtAfi6ex9E6LWOrPeG2YjoU7nzBpa0XFbxGgapXRInA+HJyASwjHN95kLY4UJs+lkG2LfBAoDXk1qg==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-17.2.4.tgz", + "integrity": "sha512-pfRQg1gY1NsQkBNAYYeSYJjejKwz1z+9bKWor8/8toCNbvh9TYMOKpcz3FrNvhR6v/Hto/quddajaxjD81TOgg==", "dependencies": { - "ngx-infinite-scroll": ">=17.0.0", + "ngx-infinite-scroll": ">=18.0.0 || >=19.0.0", "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": ">=17.0.0", - "@angular/common": ">=17.0.0", - "@angular/core": ">=17.0.0" + "@angular/cdk": ">=18.1.0 || >=19.0.0", + "@angular/common": ">=18.1.0 || >=19.0.0", + "@angular/core": ">=18.1.0 || >=19.0.0" } }, "node_modules/ngx-color-picker": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-16.0.0.tgz", - "integrity": "sha512-Dk2FvcbebD6STZSVzkI5oFHOlTrrNC5bOHh+YVaFgaWuWrVUdVIJm68ocUvTgr/qxTEJjrfcnRnS4wi7BJ2hKg==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-19.0.0.tgz", + "integrity": "sha512-jZs7nk/DJB6FryElYnfkojWYCgpEc650s800g+39ebocVMZ18fAHf/CQd5+Bdm4E3zoRod0a0sErJ+c8tGQcCg==", "dependencies": { "tslib": "^2.3.0" }, @@ -10798,16 +7445,15 @@ } }, "node_modules/ngx-extended-pdf-viewer": { - "version": "18.1.14", - "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-18.1.14.tgz", - "integrity": "sha512-8nz0sQWPn3BrN8rLy0vHrORZ3FJWPKDBt2eOJANxTmEKr0mkVECHqOoK47EfZMrc/+zwCzJkzTskA9w3CzJM/A==", + "version": "23.0.0-alpha.7", + "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-23.0.0-alpha.7.tgz", + "integrity": "sha512-S5jI9Z6p6wglLwvpf85MddxGKYUiJczb02nZcFWztDSZ7BlKXkjdtssW+chBOc/sg46p2kTDoa0M/R07yqRFcA==", "dependencies": { - "lodash.deburr": "^4.1.0", "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": ">=12.0.0 <18.0.0", - "@angular/core": ">=12.0.0 <18.0.0" + "@angular/common": ">=17.0.0 <20.0.0", + "@angular/core": ">=17.0.0 <20.0.0" } }, "node_modules/ngx-file-drop": { @@ -10827,31 +7473,15 @@ } }, "node_modules/ngx-infinite-scroll": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-17.0.0.tgz", - "integrity": "sha512-pQXLuRiuhRuDKD3nmgyW1V08JVNBepmk6nb8qjHc5hgsWNts01+R/p33rYcRDzcut6/PWqGyrZ9o9i8swzMYMA==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-19.0.0.tgz", + "integrity": "sha512-Ft4xNNDLXoDGi2hF6ylehjxbG8JIgfoL6qDWWcebGMcbh1CEfEsh0HGkDuFlX/cBBMenRh2HFbXlYq8BAtbvLw==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": ">=17.0.0 <18.0.0", - "@angular/core": ">=17.0.0 <18.0.0" - } - }, - "node_modules/ngx-slider-v2": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/ngx-slider-v2/-/ngx-slider-v2-17.0.0.tgz", - "integrity": "sha512-226pb8TeZWhmMiQQnPVBZmos2qZua2NgMsuwumxatIBy7UwOGg8xkcUl8HvXVg+rO5w733uvPMN/yz//7rReJw==", - "deprecated": "This fork was merged into the main repo. Please use @angular-slider/ngx-slider", - "dependencies": { - "detect-passive-events": "^2.0.3", - "rxjs": "^7.8.1", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^17.0.3", - "@angular/core": "^17.0.3", - "@angular/forms": "^17.0.3" + "@angular/common": ">=19.0.0 <20.0.0", + "@angular/core": ">=19.0.0 <20.0.0" } }, "node_modules/ngx-stars": { @@ -10866,23 +7496,10 @@ "@angular/core": ">=2.0.0" } }, - "node_modules/ngx-toaster": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ngx-toaster/-/ngx-toaster-1.0.1.tgz", - "integrity": "sha512-2XxTCT7+EWffb8wDpMLiFhwwZJ4B36Y1RM4m3rgG2cCt/8edsj3UzvqZjapF5wKwB+Jz8lVuVYJ94Hztcj83Cg==", - "peerDependencies": { - "@angular/common": ">=4.3.0 || >5.0.0", - "@angular/compiler": ">=4.3.0 || >5.0.0", - "@angular/core": ">=4.3.0 || >5.0.0", - "@angular/forms": ">=4.3.0 || >5.0.0", - "rxjs": ">=5.4.3", - "typescript": ">=2.3.0" - } - }, "node_modules/ngx-toastr": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-18.0.0.tgz", - "integrity": "sha512-jZ3rOG6kygl8ittY8OltIMSo47P1VStuS01igm3MZXK6InJwHVvxU7wDHI/HGMlXSyNvWncyOuFHnnMEAifsew==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-19.0.0.tgz", + "integrity": "sha512-6pTnktwwWD+kx342wuMOWB4+bkyX9221pAgGz3SHOJH0/MI9erLucS8PeeJDFwbUYyh75nQ6AzVtolgHxi52dQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -10892,25 +7509,10 @@ "@angular/platform-browser": ">=16.0.0-0" } }, - "node_modules/nice-napi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", - "integrity": "sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "!win32" - ], - "dependencies": { - "node-addon-api": "^3.0.0", - "node-gyp-build": "^4.2.2" - } - }, "node_modules/node-addon-api": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", - "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true, "optional": true }, @@ -10933,49 +7535,52 @@ } } }, - "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", - "dev": true, - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/node-gyp": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.1.0.tgz", - "integrity": "sha512-B4J5M1cABxPc5PwfjhbV5hoy2DP9p8lFXASnEN6hugXOa61416tnTZ29x9sSwAd0o99XNIcpvDDy1swAExsVKA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.1.0.tgz", + "integrity": "sha512-/+7TuHKnBpnMvUQnsYEb0JOozDZqarQbfNuSGLXIjhStMT0fbw7IdSqWgopOP5xhRZE+lsbIvAHcekddruPZgQ==", "dev": true, "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^10.3.10", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^13.0.0", - "nopt": "^7.0.0", - "proc-log": "^3.0.0", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^4.0.0" + "tar": "^7.4.3", + "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/node-gyp-build": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.0.tgz", - "integrity": "sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==", + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", "dev": true, "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "engines": { + "node": ">=18" } }, "node_modules/node-gyp/node_modules/isexe": { @@ -10987,10 +7592,42 @@ "node": ">=16" } }, + "node_modules/node-gyp/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/node-gyp/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -10999,65 +7636,36 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/node-machine-id": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", - "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==", - "dev": true + "node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "engines": { + "node": ">=18" + } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" }, "node_modules/nopt": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz", - "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", "dev": true, "dependencies": { - "abbrev": "^2.0.0" + "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz", - "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==", - "dev": true, - "dependencies": { - "hosted-git-info": "^7.0.0", - "is-core-module": "^2.8.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nosleep.js": { @@ -11066,118 +7674,97 @@ "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==" }, "node_modules/npm-bundled": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz", - "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", + "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", "dev": true, "dependencies": { - "npm-normalize-package-bin": "^3.0.0" + "npm-normalize-package-bin": "^4.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-install-checks": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", - "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.1.tgz", + "integrity": "sha512-u6DCwbow5ynAX5BdiHQ9qvexme4U3qHW3MWe5NqH+NeBm0LbiH6zvGjNNew1fY+AZZUtVHbOPF3j7mJxbUzpXg==", "dev": true, "dependencies": { "semver": "^7.1.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-normalize-package-bin": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", - "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-package-arg": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.1.tgz", - "integrity": "sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", + "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", "dev": true, "dependencies": { - "hosted-git-info": "^7.0.0", - "proc-log": "^3.0.0", + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" + "validate-npm-package-name": "^6.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-packlist": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz", - "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-9.0.0.tgz", + "integrity": "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ==", "dev": true, "dependencies": { - "ignore-walk": "^6.0.4" + "ignore-walk": "^7.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-pick-manifest": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz", - "integrity": "sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", + "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", "dev": true, "dependencies": { - "npm-install-checks": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "npm-package-arg": "^11.0.0", + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", "semver": "^7.3.5" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-registry-fetch": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.2.1.tgz", - "integrity": "sha512-8l+7jxhim55S85fjiDGJ1rZXBWGtRLi1OSb4Z3BPLObPuIaeKRlPRiYMSHU4/81ck3t71Z+UwDDl47gcpmfQQA==", + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", + "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", "dev": true, "dependencies": { - "@npmcli/redact": "^1.1.0", - "make-fetch-happen": "^13.0.0", + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", - "minipass-json-stream": "^1.0.1", - "minizlib": "^2.1.2", - "npm-package-arg": "^11.0.0", - "proc-log": "^4.0.0" + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm-registry-fetch/node_modules/proc-log": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.0.0.tgz", - "integrity": "sha512-v1lzmYxGDs2+OZnmYtYZK3DG8zogt+CbQ+o/iqqtTfpyCmGWulCTEQu5GIbivf7OjgIkH2Nr8SH8UxAGugZNbg==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nth-check": { @@ -11192,253 +7779,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nx": { - "version": "18.2.4", - "resolved": "https://registry.npmjs.org/nx/-/nx-18.2.4.tgz", - "integrity": "sha512-GxqJcDOhfLa9jsPmip0jG73CZKA96wCryss2DhixCiCU66I3GLYF4+585ObO8Tx7Z1GqhT92RaNGjCxjMIwaPg==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@nrwl/tao": "18.2.4", - "@yarnpkg/lockfile": "^1.1.0", - "@yarnpkg/parsers": "3.0.0-rc.46", - "@zkochan/js-yaml": "0.0.6", - "axios": "^1.6.0", - "chalk": "^4.1.0", - "cli-cursor": "3.1.0", - "cli-spinners": "2.6.1", - "cliui": "^8.0.1", - "dotenv": "~16.3.1", - "dotenv-expand": "~10.0.0", - "enquirer": "~2.3.6", - "figures": "3.2.0", - "flat": "^5.0.2", - "fs-extra": "^11.1.0", - "ignore": "^5.0.4", - "jest-diff": "^29.4.1", - "js-yaml": "4.1.0", - "jsonc-parser": "3.2.0", - "lines-and-columns": "~2.0.3", - "minimatch": "9.0.3", - "node-machine-id": "1.1.12", - "npm-run-path": "^4.0.1", - "open": "^8.4.0", - "ora": "5.3.0", - "semver": "^7.5.3", - "string-width": "^4.2.3", - "strong-log-transformer": "^2.1.0", - "tar-stream": "~2.2.0", - "tmp": "~0.2.1", - "tsconfig-paths": "^4.1.2", - "tslib": "^2.3.0", - "yargs": "^17.6.2", - "yargs-parser": "21.1.1" - }, - "bin": { - "nx": "bin/nx.js", - "nx-cloud": "bin/nx-cloud.js" - }, - "optionalDependencies": { - "@nx/nx-darwin-arm64": "18.2.4", - "@nx/nx-darwin-x64": "18.2.4", - "@nx/nx-freebsd-x64": "18.2.4", - "@nx/nx-linux-arm-gnueabihf": "18.2.4", - "@nx/nx-linux-arm64-gnu": "18.2.4", - "@nx/nx-linux-arm64-musl": "18.2.4", - "@nx/nx-linux-x64-gnu": "18.2.4", - "@nx/nx-linux-x64-musl": "18.2.4", - "@nx/nx-win32-arm64-msvc": "18.2.4", - "@nx/nx-win32-x64-msvc": "18.2.4" - }, - "peerDependencies": { - "@swc-node/register": "^1.8.0", - "@swc/core": "^1.3.85" - }, - "peerDependenciesMeta": { - "@swc-node/register": { - "optional": true - }, - "@swc/core": { - "optional": true - } - } - }, - "node_modules/nx/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/nx/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/nx/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/nx/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/nx/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/nx/node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/nx/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/nx/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/nx/node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true - }, - "node_modules/nx/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/nx/node_modules/ora": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.3.0.tgz", - "integrity": "sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==", - "dev": true, - "dependencies": { - "bl": "^4.0.3", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "log-symbols": "^4.0.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nx/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11461,23 +7801,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -11526,69 +7849,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ora/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/ora/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/ora/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/ordered-binary": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.3.tgz", + "integrity": "sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==", + "dev": true, + "optional": true }, "node_modules/os-tmpdir": { "version": "1.0.2", @@ -11599,109 +7865,47 @@ "node": ">=0.10.0" } }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "dev": true, - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-retry/node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/pacote": { - "version": "17.0.6", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.6.tgz", - "integrity": "sha512-cJKrW21VRE8vVTRskJo78c/RCvwJCn1f4qgfxL4w77SOWrTCRcmfkYHlHtS0gqpgjv3zhXflRtgsrUCX5xwNnQ==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-20.0.0.tgz", + "integrity": "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A==", "dev": true, "dependencies": { - "@npmcli/git": "^5.0.0", - "@npmcli/installed-package-contents": "^2.0.1", - "@npmcli/promise-spawn": "^7.0.0", - "@npmcli/run-script": "^7.0.0", - "cacache": "^18.0.0", + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", - "npm-package-arg": "^11.0.0", - "npm-packlist": "^8.0.0", - "npm-pick-manifest": "^9.0.0", - "npm-registry-fetch": "^16.0.0", - "proc-log": "^3.0.0", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1", - "read-package-json": "^7.0.0", - "read-package-json-fast": "^3.0.0", - "sigstore": "^2.2.0", - "ssri": "^10.0.0", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", "tar": "^6.1.11" }, "bin": { - "pacote": "lib/bin.js" + "pacote": "bin/index.js" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/parent-module": { @@ -11742,20 +7946,10 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, - "node_modules/parse-node-version": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", - "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "devOptional": true, "dependencies": { "entities": "^4.4.0" }, @@ -11789,15 +7983,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11807,15 +7992,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -11853,12 +8029,6 @@ "node": "14 || >=16.14" } }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", - "dev": true - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -11868,14 +8038,14 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", - "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "engines": { "node": ">=12" @@ -11884,126 +8054,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/piscina": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.4.0.tgz", - "integrity": "sha512-+AQduEJefrOApE4bV7KRmp3N2JnnyErlVqq4P/jmko4FPz9Z877BCccl/iB3FdrWSUkvbGV9Kan/KllJgat3Vg==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.8.0.tgz", + "integrity": "sha512-EZJb+ZxDrQf3dihsUL7p42pjNyrNIFJCrRHPMgxu/svsj+P3xS3fuEWp7k2+rfsavfl1N0G29b1HGs7J0m8rZA==", "dev": true, "optionalDependencies": { - "nice-napi": "^1.0.2" - } - }, - "node_modules/pkg-dir": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", - "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", - "dev": true, - "dependencies": { - "find-up": "^6.3.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "dev": true, - "dependencies": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/pkg-dir/node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", - "dev": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "@napi-rs/nice": "^1.0.1" } }, "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { @@ -12020,173 +8083,20 @@ } ], "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-loader": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", - "integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==", - "dev": true, - "dependencies": { - "cosmiconfig": "^9.0.0", - "jiti": "^1.20.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "postcss": "^7.0.0 || ^8.0.1", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/postcss-loader/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/postcss-loader/node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/postcss-loader/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "dev": true, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.5.tgz", - "integrity": "sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==", - "dev": true, - "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^6.0.2", - "postcss-value-parser": "^4.1.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-scope": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.0.tgz", - "integrity": "sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==", - "dev": true, - "dependencies": { - "postcss-selector-parser": "^6.0.4" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "dev": true, - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", - "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -12196,53 +8106,15 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/proc-log": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", - "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true - }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -12256,41 +8128,6 @@ "node": ">=10" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true - }, - "node_modules/prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", - "dev": true, - "optional": true - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -12304,21 +8141,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dev": true, - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -12343,82 +8165,6 @@ } ] }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, - "node_modules/read-package-json": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.0.tgz", - "integrity": "sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg==", - "dev": true, - "dependencies": { - "glob": "^10.2.2", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/read-package-json-fast": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz", - "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==", - "dev": true, - "dependencies": { - "json-parse-even-better-errors": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -12432,110 +8178,12 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" - }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true }, - "node_modules/regenerate-unicode-properties": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", - "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", - "dev": true, - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true - }, - "node_modules/regenerator-transform": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", - "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.8.4" - } - }, - "node_modules/regex-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.0.tgz", - "integrity": "sha512-TVILVSz2jY5D47F4mA4MppkBrafEaiUWJO/TcZHEIuI13AqoZMkK1WMA4Om1YkYbTx+9Ki1/tSUXbceyr9saRg==", - "dev": true - }, - "node_modules/regexpu-core": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", - "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", - "dev": true, - "dependencies": { - "@babel/regjsgen": "^0.8.0", - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.1.0", - "regjsparser": "^0.9.1", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsparser": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", - "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", - "dev": true, - "dependencies": { - "jsesc": "~0.5.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - } - }, "node_modules/replace-in-file": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/replace-in-file/-/replace-in-file-7.1.0.tgz", @@ -12552,51 +8200,6 @@ "node": ">=10" } }, - "node_modules/replace-in-file/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/replace-in-file/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/replace-in-file/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/replace-in-file/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "node_modules/replace-in-file/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -12615,14 +8218,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/replace-in-file/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, "node_modules/replace-in-file/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -12634,17 +8229,6 @@ "node": ">=10" } }, - "node_modules/replace-in-file/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -12668,70 +8252,25 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dev": true, "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-url-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", - "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", - "dev": true, - "dependencies": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.14", - "source-map": "0.6.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/resolve-url-loader/node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/resolve-url-loader/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -12768,74 +8307,18 @@ } }, "node_modules/rfdc": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz", - "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==" - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true }, "node_modules/rollup": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.2.tgz", - "integrity": "sha512-WkeoTWvuBoFjFAhsEOHKRoZ3r9GfTyhh7Vff1zwebEFLEFjT1lG3784xEgKiTa7E+e70vsC81roVL2MP4tgEEQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", + "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", "dev": true, "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -12845,33 +8328,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.2", - "@rollup/rollup-android-arm64": "4.14.2", - "@rollup/rollup-darwin-arm64": "4.14.2", - "@rollup/rollup-darwin-x64": "4.14.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.2", - "@rollup/rollup-linux-arm64-gnu": "4.14.2", - "@rollup/rollup-linux-arm64-musl": "4.14.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.2", - "@rollup/rollup-linux-riscv64-gnu": "4.14.2", - "@rollup/rollup-linux-s390x-gnu": "4.14.2", - "@rollup/rollup-linux-x64-gnu": "4.14.2", - "@rollup/rollup-linux-x64-musl": "4.14.2", - "@rollup/rollup-win32-arm64-msvc": "4.14.2", - "@rollup/rollup-win32-ia32-msvc": "4.14.2", - "@rollup/rollup-win32-x64-msvc": "4.14.2", + "@rollup/rollup-android-arm-eabi": "4.34.8", + "@rollup/rollup-android-arm64": "4.34.8", + "@rollup/rollup-darwin-arm64": "4.34.8", + "@rollup/rollup-darwin-x64": "4.34.8", + "@rollup/rollup-freebsd-arm64": "4.34.8", + "@rollup/rollup-freebsd-x64": "4.34.8", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", + "@rollup/rollup-linux-arm-musleabihf": "4.34.8", + "@rollup/rollup-linux-arm64-gnu": "4.34.8", + "@rollup/rollup-linux-arm64-musl": "4.34.8", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", + "@rollup/rollup-linux-riscv64-gnu": "4.34.8", + "@rollup/rollup-linux-s390x-gnu": "4.34.8", + "@rollup/rollup-linux-x64-gnu": "4.34.8", + "@rollup/rollup-linux-x64-musl": "4.34.8", + "@rollup/rollup-win32-arm64-msvc": "4.34.8", + "@rollup/rollup-win32-ia32-msvc": "4.34.8", + "@rollup/rollup-win32-x64-msvc": "4.34.8", "fsevents": "~2.3.2" } }, - "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12895,9 +8373,9 @@ } }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dependencies": { "tslib": "^2.1.0" } @@ -12925,16 +8403,16 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "dev": true }, "node_modules/sass": { - "version": "1.71.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.71.1.tgz", - "integrity": "sha512-wovtnV2PxzteLlfNzbgm1tFXPLoZILYAMJtvoXXkD7/+1uP41eKkIt1ypWq5/q2uT94qHjXehEYfmjKOvjL9sg==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", + "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", "dev": true, "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "chokidar": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -12942,72 +8420,37 @@ }, "engines": { "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, - "node_modules/sass-loader": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-14.1.1.tgz", - "integrity": "sha512-QX8AasDg75monlybel38BZ49JP5Z+uSKfKwF2rO7S74BywaRmGQMUBw9dtkS+ekyM/QnP+NOrRYq8ABMZ9G8jw==", + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "dependencies": { - "neo-async": "^2.6.2" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 18.12.0" + "node": ">= 14.16.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "node-sass": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "webpack": { - "optional": true - } + "url": "https://paulmillr.com/funding/" } }, - "node_modules/sax": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", - "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==", + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "optional": true - }, - "node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, "engines": { - "node": ">= 12.13.0" + "node": ">= 14.18.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/screenfull": { @@ -13021,32 +8464,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "dev": true - }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", - "dev": true, - "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -13054,209 +8476,11 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", - "dev": true, - "dependencies": { - "accepts": "~1.3.4", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", - "dev": true, - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "dev": true - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/serve-index/node_modules/setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", - "dev": true - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dev": true, - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13276,33 +8500,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -13315,20 +8512,20 @@ } }, "node_modules/sigstore": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.3.0.tgz", - "integrity": "sha512-q+o8L2ebiWD1AxD17eglf1pFrl9jtW7FHa0ygqY6EKvibK8JHyq9Z26v9MZXeDiw+RbfOJ9j2v70M10Hd6E06A==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", + "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.3.1", - "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.1", - "@sigstore/sign": "^2.3.0", - "@sigstore/tuf": "^2.3.1", - "@sigstore/verify": "^1.2.0" + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/sirv": { @@ -13345,13 +8542,44 @@ "node": ">= 10" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/smart-buffer": { @@ -13364,21 +8592,10 @@ "npm": ">= 3.0.0" } }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "dev": true, - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "dev": true, "dependencies": { "ip-address": "^9.0.5", @@ -13390,14 +8607,14 @@ } }, "node_modules/socks-proxy-agent": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.3.tgz", - "integrity": "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, "dependencies": { - "agent-base": "^7.1.1", + "agent-base": "^7.1.2", "debug": "^4.3.4", - "socks": "^2.7.1" + "socks": "^2.8.3" }, "engines": { "node": ">= 14" @@ -13413,46 +8630,14 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", - "dev": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.72.1" - } - }, - "node_modules/source-map-loader/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -13499,45 +8684,15 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", - "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", "dev": true }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "dev": true, - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "dev": true, - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, "node_modules/ssr-window": { @@ -13546,24 +8701,15 @@ "integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==" }, "node_modules/ssri": { - "version": "10.0.5", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz", - "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", "dev": true, "dependencies": { "minipass": "^7.0.3" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "engines": { - "node": ">= 0.8" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/string_decoder": { @@ -13624,24 +8770,6 @@ "node": ">=8" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -13654,32 +8782,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strong-log-transformer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz", - "integrity": "sha512-B3Hgul+z0L9a236FAUC9iZsL+nVHgoCJnqCbN588DjYxvGXaXaaFbfmQ/JhvKjZwsOukuR72XbHv71Qkug0HxA==", - "dev": true, - "dependencies": { - "duplexer": "^0.1.1", - "minimist": "^1.2.0", - "through": "^2.3.4" - }, - "bin": { - "sl-log-transformer": "bin/sl-log-transformer.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dependencies": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -13726,15 +8837,6 @@ "node": ">=0.10" } }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -13752,22 +8854,6 @@ "node": ">=10" } }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tar/node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -13801,202 +8887,61 @@ "node": ">=8" } }, + "node_modules/tar/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tar/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/terser": { - "version": "5.29.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.29.1.tgz", - "integrity": "sha512-lZQ/fyaIGxsbGxApKmoPTODIzELy3++mXhS5hOqaAWZjQtpq/hFHAc+rm29NND1rYRxRWKcjuARNwULNXa5RtQ==", - "dev": true, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" + }, + "node_modules/tinygradient": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", + "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" + "@types/tinycolor2": "^1.4.0", + "tinycolor2": "^1.0.0" } }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", - "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.20", - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.26.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true - }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "dev": true, + "dependencies": { + "os-tmpdir": "~1.0.2" + }, "engines": { - "node": ">=14.14" - } - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" + "node": ">=0.6.0" } }, "node_modules/to-regex-range": { @@ -14010,15 +8955,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -14055,25 +8991,16 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-node": { @@ -14119,37 +9046,23 @@ } } }, - "node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tuf-js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.0.tgz", - "integrity": "sha512-ZSDngmP1z6zw+FIkIBjvOp/II/mIub/O7Pp12j1WNsiCpg5R5wAc//i555bBQsE44O94btLt0xM/Zr2LQjwdCg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", + "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", "dev": true, "dependencies": { - "@tufjs/models": "2.0.0", - "debug": "^4.3.4", - "make-fetch-happen": "^13.0.0" + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/type-check": { @@ -14176,29 +9089,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typed-assert": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", - "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", - "dev": true - }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14207,83 +9102,34 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.11.1.tgz", - "integrity": "sha512-KyhzaLJnV1qa3BSHdj4AZ2ndqI0QWPxYzaIOio0WzcEJB9gvuysprJSLtpvc2D9mhR9jPDUk7xlJlZbH2KR5iw==", - "dev": true, - "engines": { - "node": ">=18.0" - } - }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "dev": true }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", - "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", - "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/unique-filename": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", - "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", "dev": true, "dependencies": { - "unique-slug": "^4.0.0" + "unique-slug": "^5.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/unique-slug": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", - "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", "dev": true, "dependencies": { "imurmurhash": "^0.1.4" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/universalify": { @@ -14294,19 +9140,10 @@ "node": ">= 10.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "funding": [ { "type": "opencollective", @@ -14322,8 +9159,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -14355,24 +9192,6 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -14390,41 +9209,29 @@ } }, "node_modules/validate-npm-package-name": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz", - "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==", - "dev": true, - "dependencies": { - "builtins": "^5.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", + "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", "dev": true, "engines": { - "node": ">= 0.8" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/vite": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.7.tgz", - "integrity": "sha512-sgnEEFTZYMui/sTlH1/XEnVNHMujOahPLGMxn1+5sIT45Xjng1Ec1K78jRP15dSmVgg5WBin9yO81j3o9OxofA==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz", + "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", "dev": true, "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.35", - "rollup": "^4.2.0" + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -14433,18 +9240,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -14454,6 +9268,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -14462,419 +9279,19 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" - } - }, "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", @@ -14884,15 +9301,6 @@ "node": ">=10.13.0" } }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "dev": true, - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -14901,58 +9309,18 @@ "defaults": "^1.0.3" } }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "optional": true + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, - "node_modules/webpack": { - "version": "5.90.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.3.tgz", - "integrity": "sha512-h6uDYlWCctQRuXBs1oYpVe6sFcWedl0dpcVaTf/YF67J9bKvwJajFulMVSYKHrksMB3I/pIagRzDxwxkebuzKA==", - "dev": true, - "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", - "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.21.10", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.0", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, "node_modules/webpack-bundle-analyzer": { "version": "4.10.2", "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", @@ -15000,290 +9368,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-dev-middleware": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.1.2.tgz", - "integrity": "sha512-Wu+EHmX326YPYUpQLKmKbTyZZJIB8/n6R09pTmB03kJmnMsVPTo9COzHZFr01txwaCAuZvfBJE4ZCHRcKs5JaQ==", - "dev": true, - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^3.4.12", - "mime-types": "^2.1.31", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server": { - "version": "4.15.1", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", - "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", - "dev": true, - "dependencies": { - "@types/bonjour": "^3.5.9", - "@types/connect-history-api-fallback": "^1.3.5", - "@types/express": "^4.17.13", - "@types/serve-index": "^1.9.1", - "@types/serve-static": "^1.13.10", - "@types/sockjs": "^0.3.33", - "@types/ws": "^8.5.5", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.0.11", - "chokidar": "^3.5.3", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "default-gateway": "^6.0.3", - "express": "^4.17.3", - "graceful-fs": "^4.2.6", - "html-entities": "^2.3.2", - "http-proxy-middleware": "^2.0.3", - "ipaddr.js": "^2.0.1", - "launch-editor": "^2.6.0", - "open": "^8.0.9", - "p-retry": "^4.5.0", - "rimraf": "^3.0.2", - "schema-utils": "^4.0.0", - "selfsigned": "^2.1.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^5.3.1", - "ws": "^8.13.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.37.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", - "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", - "dev": true, - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^3.4.3", - "mime-types": "^2.1.31", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/webpack-merge": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", - "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", - "dev": true, - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/webpack-merge/node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack-subresource-integrity": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz", - "integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==", - "dev": true, - "dependencies": { - "typed-assert": "^1.0.8" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "html-webpack-plugin": ">= 5.0.0-beta.1 < 6", - "webpack": "^5.12.0" - }, - "peerDependenciesMeta": { - "html-webpack-plugin": { - "optional": true - } - } - }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/webpack/node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dev": true, - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -15307,12 +9391,6 @@ "node": ">= 8" } }, - "node_modules/wildcard": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", - "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", - "dev": true - }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -15344,78 +9422,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "engines": { "node": ">=8.3.0" }, @@ -15491,13 +9506,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zone.js": { - "version": "0.14.4", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.4.tgz", - "integrity": "sha512-NtTUvIlNELez7Q1DzKVIFZBzNb646boQMgpATo9z3Ftuu/gWvzxCW7jdjcUDoRGxRikrhVHB/zLXh1hxeJawvw==", - "dependencies": { - "tslib": "^2.3.0" + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zone.js": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz", + "integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==" } } } diff --git a/UI/Web/package.json b/UI/Web/package.json index ec2df9657..05d539aed 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -16,70 +16,70 @@ }, "private": true, "dependencies": { - "@angular/animations": "^17.3.4", - "@angular/cdk": "^17.3.4", - "@angular/common": "^17.3.4", - "@angular/compiler": "^17.3.4", - "@angular/core": "^17.3.4", - "@angular/forms": "^17.3.4", - "@angular/localize": "^17.3.4", - "@angular/platform-browser": "^17.3.4", - "@angular/platform-browser-dynamic": "^17.3.4", - "@angular/router": "^17.3.4", - "@fortawesome/fontawesome-free": "^6.5.2", - "@iharbeck/ngx-virtual-scroller": "^17.0.2", - "@iplab/ngx-file-upload": "^17.1.0", - "@jsverse/transloco": "^7.4.3", + "@angular-slider/ngx-slider": "^19.0.0", + "@angular/animations": "^19.2.5", + "@angular/cdk": "^19.2.8", + "@angular/common": "^19.2.5", + "@angular/compiler": "^19.2.5", + "@angular/core": "^19.2.5", + "@angular/forms": "^19.2.5", + "@angular/localize": "^19.2.5", + "@angular/platform-browser": "^19.2.5", + "@angular/platform-browser-dynamic": "^19.2.5", + "@angular/router": "^19.2.5", + "@fortawesome/fontawesome-free": "^6.7.2", + "@iharbeck/ngx-virtual-scroller": "^19.0.1", + "@iplab/ngx-file-upload": "^19.0.3", + "@jsverse/transloco": "^7.6.1", "@jsverse/transloco-locale": "^7.0.1", - "@jsverse/transloco-persist-lang": "^7.0.1", + "@jsverse/transloco-persist-lang": "^7.0.2", "@jsverse/transloco-persist-translations": "^7.0.1", "@jsverse/transloco-preload-langs": "^7.0.1", - "@microsoft/signalr": "^7.0.14", - "@ng-bootstrap/ng-bootstrap": "^16.0.0", + "@microsoft/signalr": "^8.0.7", + "@ng-bootstrap/ng-bootstrap": "^18.0.0", "@popperjs/core": "^2.11.7", - "@swimlane/ngx-charts": "^20.5.0", - "@tweenjs/tween.js": "^23.1.1", + "@siemens/ngx-datatable": "^22.4.1", + "@swimlane/ngx-charts": "^22.0.0-alpha.0", + "@tweenjs/tween.js": "^25.0.0", "bootstrap": "^5.3.2", "charts.css": "^1.1.0", "file-saver": "^2.0.5", - "luxon": "^3.4.4", + "luxon": "^3.6.1", "ng-circle-progress": "^1.7.1", "ng-lazyload-image": "^9.1.3", - "ng-select2-component": "^14.0.1", - "ngx-color-picker": "^16.0.0", - "ngx-extended-pdf-viewer": "^18.1.9", + "ng-select2-component": "^17.2.4", + "ngx-color-picker": "^19.0.0", + "ngx-extended-pdf-viewer": "^23.0.0-alpha.7", "ngx-file-drop": "^16.0.0", - "ngx-slider-v2": "^17.0.0", "ngx-stars": "^1.6.5", - "ngx-toaster": "^1.0.1", - "ngx-toastr": "^18.0.0", + "ngx-toastr": "^19.0.0", "nosleep.js": "^0.12.0", - "rxjs": "^7.8.0", + "rxjs": "^7.8.2", "screenfull": "^6.0.2", "swiper": "^8.4.6", - "tslib": "^2.6.2", - "zone.js": "^0.14.3" + "tslib": "^2.8.1", + "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "^17.3.4", - "@angular-eslint/builder": "^17.3.0", - "@angular-eslint/eslint-plugin": "^17.3.0", - "@angular-eslint/eslint-plugin-template": "^17.3.0", - "@angular-eslint/schematics": "^17.3.0", - "@angular-eslint/template-parser": "^17.3.0", - "@angular/cli": "^17.3.4", - "@angular/compiler-cli": "^17.3.4", + "@angular-eslint/builder": "^19.3.0", + "@angular-eslint/eslint-plugin": "^19.3.0", + "@angular-eslint/eslint-plugin-template": "^19.3.0", + "@angular-eslint/schematics": "^19.3.0", + "@angular-eslint/template-parser": "^19.3.0", + "@angular/build": "^19.2.6", + "@angular/cli": "^19.2.6", + "@angular/compiler-cli": "^19.2.5", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", - "@types/luxon": "^3.4.0", - "@types/node": "^20.10.0", - "@typescript-eslint/eslint-plugin": "^7.2.0", - "@typescript-eslint/parser": "^7.2.0", - "eslint": "^8.57.0", + "@types/luxon": "^3.6.2", + "@types/node": "^22.13.13", + "@typescript-eslint/eslint-plugin": "^8.28.0", + "@typescript-eslint/parser": "^8.28.0", + "eslint": "^9.23.0", "jsonminify": "^0.4.2", "karma-coverage": "~2.2.0", "ts-node": "~10.9.1", - "typescript": "^5.2.2", + "typescript": "^5.5.4", "webpack-bundle-analyzer": "^4.10.2" } } diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss index f27a8768b..1c6f916f3 100644 --- a/UI/Web/src/_card-item-common.scss +++ b/UI/Web/src/_card-item-common.scss @@ -163,11 +163,15 @@ $image-width: 160px; align-items: center; padding: 0 5px; + :first-child { + min-width: 22px; + } + .card-title { font-size: 0.8rem; margin: 0; text-align: center; - max-width: 98px; + max-width: 90px; a { overflow: hidden; diff --git a/UI/Web/src/_series-detail-common.scss b/UI/Web/src/_series-detail-common.scss index 214047a93..efb54f860 100644 --- a/UI/Web/src/_series-detail-common.scss +++ b/UI/Web/src/_series-detail-common.scss @@ -1,4 +1,4 @@ -@import './theme/variables'; +@use './theme/variables' as theme; .title { color: white; @@ -13,7 +13,7 @@ } .subtitle { - color: lightgrey; + color: var(--detail-subtitle-color); font-weight: bold; font-size: 0.8rem; } @@ -149,7 +149,7 @@ object-fit: contain; } -@media (max-width: $grid-breakpoints-lg) { +@media (max-width: theme.$grid-breakpoints-lg) { .carousel-tabs-container { mask-image: linear-gradient(transparent, black 0%, black 90%, transparent 100%); -webkit-mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%); @@ -162,7 +162,7 @@ } /* col-lg */ -@media (max-width: $grid-breakpoints-lg) { +@media (max-width: theme.$grid-breakpoints-lg) { .image-container.mobile-bg{ width: 100vw; top: calc(var(--nav-offset) - 20px); @@ -201,7 +201,7 @@ font-size: 0.9rem; } -@media (max-width: $grid-breakpoints-lg) { +@media (max-width: theme.$grid-breakpoints-lg) { .carousel-tabs-container { mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%); -webkit-mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%); diff --git a/UI/Web/src/_tag-card-common.scss b/UI/Web/src/_tag-card-common.scss new file mode 100644 index 000000000..39a1e87fd --- /dev/null +++ b/UI/Web/src/_tag-card-common.scss @@ -0,0 +1,35 @@ +.tag-card { + background-color: var(--bs-card-color, #2c2c2c); + padding: 1rem; + border-radius: 12px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + transition: transform 0.2s ease, background 0.3s ease; + cursor: pointer; + + &.not-selectable:hover { + cursor: not-allowed; + background-color: var(--bs-card-color, #2c2c2c) !important; + } +} + +.tag-card:hover { + background-color: #3a3a3a; + //transform: translateY(-3px); // Cool effect but has a weird background issue. ROBBIE: Fix this +} + +.tag-name { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.5rem; + max-height: 8rem; + height: 8rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.tag-meta { + font-size: 0.85rem; + display: flex; + justify-content: space-between; + color: var(--text-muted-color, #bbb); +} diff --git a/UI/Web/src/app/_directives/dbl-click.directive.ts b/UI/Web/src/app/_directives/dbl-click.directive.ts new file mode 100644 index 000000000..ab1d0bcde --- /dev/null +++ b/UI/Web/src/app/_directives/dbl-click.directive.ts @@ -0,0 +1,36 @@ +import {Directive, EventEmitter, HostListener, Output} from '@angular/core'; + +@Directive({ + selector: '[appDblClick]', + standalone: true +}) +export class DblClickDirective { + + @Output() singleClick = new EventEmitter(); + @Output() doubleClick = new EventEmitter(); + + private lastTapTime = 0; + private tapTimeout = 300; // Time threshold for a double tap (in milliseconds) + private singleClickTimeout: any; + + @HostListener('click', ['$event']) + handleClick(event: Event): void { + const currentTime = new Date().getTime(); + + if (currentTime - this.lastTapTime < this.tapTimeout) { + // Detected a double click/tap + clearTimeout(this.singleClickTimeout); // Prevent single-click emission + event.stopPropagation(); + event.preventDefault(); + this.doubleClick.emit(event); + } else { + // Delay single-click emission to check if a double-click occurs + this.singleClickTimeout = setTimeout(() => { + this.singleClick.emit(event); // Optional: emit single-click if no double-click follows + }, this.tapTimeout); + } + + this.lastTapTime = currentTime; + } + +} diff --git a/UI/Web/src/app/_directives/enter-blur.directive.ts b/UI/Web/src/app/_directives/enter-blur.directive.ts new file mode 100644 index 000000000..30329f724 --- /dev/null +++ b/UI/Web/src/app/_directives/enter-blur.directive.ts @@ -0,0 +1,13 @@ +import { Directive, HostListener } from '@angular/core'; + +@Directive({ + selector: '[appEnterBlur]', + standalone: true, +}) +export class EnterBlurDirective { + @HostListener('keydown.enter', ['$event']) + onEnter(event: KeyboardEvent): void { + event.preventDefault(); + document.body.click(); + } +} diff --git a/UI/Web/src/app/_helpers/browser.ts b/UI/Web/src/app/_helpers/browser.ts new file mode 100644 index 000000000..4d92e207c --- /dev/null +++ b/UI/Web/src/app/_helpers/browser.ts @@ -0,0 +1,62 @@ +export const isSafari = [ + 'iPad Simulator', + 'iPhone Simulator', + 'iPod Simulator', + 'iPad', + 'iPhone', + 'iPod' + ].includes(navigator.platform) + // iPad on iOS 13 detection + || (navigator.userAgent.includes("Mac") && "ontouchend" in document); + +/** + * Represents a Version for a browser + */ +export class Version { + major: number; + minor: number; + patch: number; + + constructor(major: number, minor: number, patch: number) { + this.major = major; + this.minor = minor; + this.patch = patch; + } + + isLessThan(other: Version): boolean { + if (this.major < other.major) return true; + if (this.major > other.major) return false; + if (this.minor < other.minor) return true; + if (this.minor > other.minor) return false; + return this.patch < other.patch; + } + + isGreaterThan(other: Version): boolean { + if (this.major > other.major) return true; + if (this.major < other.major) return false; + if (this.minor > other.minor) return true; + if (this.minor < other.minor) return false; + return this.patch > other.patch; + } + + isEqualTo(other: Version): boolean { + return ( + this.major === other.major && + this.minor === other.minor && + this.patch === other.patch + ); + } +} + + +export const getIosVersion = () => { + const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/); + if (match) { + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + const patch = parseInt(match[3] || '0', 10); + + return new Version(major, minor, patch); + } + return null; +} diff --git a/UI/Web/src/app/_helpers/form-debug.ts b/UI/Web/src/app/_helpers/form-debug.ts new file mode 100644 index 000000000..4ad70ac87 --- /dev/null +++ b/UI/Web/src/app/_helpers/form-debug.ts @@ -0,0 +1,120 @@ +import {AbstractControl, FormArray, FormControl, FormGroup} from '@angular/forms'; + +interface ValidationIssue { + path: string; + controlType: string; + value: any; + errors: { [key: string]: any } | null; + status: string; + disabled: boolean; +} + +export function analyzeFormGroupValidation(formGroup: FormGroup, basePath: string = ''): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + function analyzeControl(control: AbstractControl, path: string): void { + // Determine control type for better debugging + let controlType = 'AbstractControl'; + if (control instanceof FormGroup) { + controlType = 'FormGroup'; + } else if (control instanceof FormArray) { + controlType = 'FormArray'; + } else if (control instanceof FormControl) { + controlType = 'FormControl'; + } + + // Add issue if control has validation errors or is invalid + if (control.invalid || control.errors || control.disabled) { + issues.push({ + path: path || 'root', + controlType, + value: control.value, + errors: control.errors, + status: control.status, + disabled: control.disabled + }); + } + + // Recursively check nested controls + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + const childPath = path ? `${path}.${key}` : key; + analyzeControl(control.controls[key], childPath); + }); + } else if (control instanceof FormArray) { + control.controls.forEach((childControl, index) => { + const childPath = path ? `${path}[${index}]` : `[${index}]`; + analyzeControl(childControl, childPath); + }); + } + } + + analyzeControl(formGroup, basePath); + return issues; +} + +export function printFormGroupValidation(formGroup: FormGroup, basePath: string = ''): void { + const issues = analyzeFormGroupValidation(formGroup, basePath); + + console.group(`🔍 FormGroup Validation Analysis (${basePath || 'root'})`); + console.log(`Overall Status: ${formGroup.status}`); + console.log(`Overall Valid: ${formGroup.valid}`); + console.log(`Total Issues Found: ${issues.length}`); + + if (issues.length === 0) { + console.log('✅ No validation issues found!'); + } else { + console.log('\n📋 Detailed Issues:'); + issues.forEach((issue, index) => { + console.group(`${index + 1}. ${issue.path} (${issue.controlType})`); + console.log(`Status: ${issue.status}`); + console.log(`Value:`, issue.value); + console.log(`Disabled: ${issue.disabled}`); + + if (issue.errors) { + console.log('Validation Errors:'); + Object.entries(issue.errors).forEach(([errorKey, errorValue]) => { + console.log(` • ${errorKey}:`, errorValue); + }); + } else { + console.log('No specific validation errors (but control is invalid)'); + } + console.groupEnd(); + }); + } + + console.groupEnd(); +} + +// Alternative function that returns a formatted string instead of console logging +export function getFormGroupValidationReport(formGroup: FormGroup, basePath: string = ''): string { + const issues = analyzeFormGroupValidation(formGroup, basePath); + + let report = `FormGroup Validation Report (${basePath || 'root'})\n`; + report += `Overall Status: ${formGroup.status}\n`; + report += `Overall Valid: ${formGroup.valid}\n`; + report += `Total Issues Found: ${issues.length}\n\n`; + + if (issues.length === 0) { + report += '✅ No validation issues found!'; + } else { + report += 'Detailed Issues:\n'; + issues.forEach((issue, index) => { + report += `\n${index + 1}. ${issue.path} (${issue.controlType})\n`; + report += ` Status: ${issue.status}\n`; + report += ` Value: ${JSON.stringify(issue.value)}\n`; + report += ` Disabled: ${issue.disabled}\n`; + + if (issue.errors) { + report += ' Validation Errors:\n'; + Object.entries(issue.errors).forEach(([errorKey, errorValue]) => { + report += ` • ${errorKey}: ${JSON.stringify(errorValue)}\n`; + }); + } else { + report += ' No specific validation errors (but control is invalid)\n'; + } + }); + } + + return report; +} diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index 39b123562..503ca4516 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -1,10 +1,5 @@ import {Injectable} from '@angular/core'; -import { - HttpRequest, - HttpHandler, - HttpEvent, - HttpInterceptor -} from '@angular/common/http'; +import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; diff --git a/UI/Web/src/app/_interceptors/jwt.interceptor.ts b/UI/Web/src/app/_interceptors/jwt.interceptor.ts index c81a784e6..711b8ee11 100644 --- a/UI/Web/src/app/_interceptors/jwt.interceptor.ts +++ b/UI/Web/src/app/_interceptors/jwt.interceptor.ts @@ -1,10 +1,5 @@ import {Injectable} from '@angular/core'; -import { - HttpRequest, - HttpHandler, - HttpEvent, - HttpInterceptor -} from '@angular/common/http'; +import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http'; import {Observable, switchMap} from 'rxjs'; import { AccountService } from '../_services/account.service'; import { take } from 'rxjs/operators'; diff --git a/UI/Web/src/app/_models/auth/member.ts b/UI/Web/src/app/_models/auth/member.ts index 31238c68b..aaa45f332 100644 --- a/UI/Web/src/app/_models/auth/member.ts +++ b/UI/Web/src/app/_models/auth/member.ts @@ -1,16 +1,16 @@ -import { AgeRestriction } from '../metadata/age-restriction'; -import { Library } from '../library/library'; +import {AgeRestriction} from '../metadata/age-restriction'; +import {Library} from '../library/library'; export interface Member { - id: number; - username: string; - email: string; - lastActive: string; // datetime - lastActiveUtc: string; // datetime - created: string; // datetime - createdUtc: string; // datetime - roles: string[]; - libraries: Library[]; - ageRestriction: AgeRestriction; - isPending: boolean; + id: number; + username: string; + email: string; + lastActive: string; // datetime + lastActiveUtc: string; // datetime + created: string; // datetime + createdUtc: string; // datetime + roles: string[]; + libraries: Library[]; + ageRestriction: AgeRestriction; + isPending: boolean; } diff --git a/UI/Web/src/app/_models/chapter-detail-plus.ts b/UI/Web/src/app/_models/chapter-detail-plus.ts new file mode 100644 index 000000000..2a17089e1 --- /dev/null +++ b/UI/Web/src/app/_models/chapter-detail-plus.ts @@ -0,0 +1,9 @@ +import {UserReview} from "../_single-module/review-card/user-review"; +import {Rating} from "./rating"; + +export type ChapterDetailPlus = { + rating: number; + hasBeenRated: boolean; + reviews: UserReview[]; + ratings: Rating[]; +}; diff --git a/UI/Web/src/app/_models/collection-tag.ts b/UI/Web/src/app/_models/collection-tag.ts index fb83c6aab..2679b5f73 100644 --- a/UI/Web/src/app/_models/collection-tag.ts +++ b/UI/Web/src/app/_models/collection-tag.ts @@ -16,6 +16,10 @@ export interface UserCollection { source: ScrobbleProvider; sourceUrl: string | null; totalSourceCount: number; + /** + * HTML anchors separated by
+ */ missingSeriesFromSource: string | null; ageRating: AgeRating; + itemCount: number; } diff --git a/UI/Web/src/app/_models/default-modal-options.ts b/UI/Web/src/app/_models/default-modal-options.ts new file mode 100644 index 000000000..4dbb391a9 --- /dev/null +++ b/UI/Web/src/app/_models/default-modal-options.ts @@ -0,0 +1 @@ +export const DefaultModalOptions = {scrollable: true, size: 'xl', fullscreen: 'xl'}; diff --git a/UI/Web/src/app/_models/email-history.ts b/UI/Web/src/app/_models/email-history.ts new file mode 100644 index 000000000..0805704fb --- /dev/null +++ b/UI/Web/src/app/_models/email-history.ts @@ -0,0 +1,7 @@ +export interface EmailHistory { + sent: boolean; + sendDate: string; + emailTemplate: string; + errorMessage: string; + toUserName: string; +} diff --git a/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts b/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts new file mode 100644 index 000000000..3695651d6 --- /dev/null +++ b/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts @@ -0,0 +1,4 @@ +export interface ExternalMatchRateLimitErrorEvent { + seriesId: number; + seriesName: string; +} diff --git a/UI/Web/src/app/_models/events/update-version-event.ts b/UI/Web/src/app/_models/events/update-version-event.ts index c74e49af6..4e7e82ce6 100644 --- a/UI/Web/src/app/_models/events/update-version-event.ts +++ b/UI/Web/src/app/_models/events/update-version-event.ts @@ -1,12 +1,26 @@ export interface UpdateVersionEvent { - currentVersion: string; - updateVersion: string; - updateBody: string; - updateTitle: string; - updateUrl: string; - isDocker: boolean; - publishDate: string; - isOnNightlyInRelease: boolean; - isReleaseNewer: boolean; - isReleaseEqual: boolean; + currentVersion: string; + updateVersion: string; + updateBody: string; + updateTitle: string; + updateUrl: string; + isDocker: boolean; + publishDate: string; + isOnNightlyInRelease: boolean; + isReleaseNewer: boolean; + isReleaseEqual: boolean; + + added: Array; + removed: Array; + changed: Array; + fixed: Array; + theme: Array; + developer: Array; + api: Array; + featureRequests: Array; + knownIssues: Array; + /** + * The part above the changelog part + */ + blogPart: string; } diff --git a/UI/Web/src/app/_models/kavitaplus/license-info.ts b/UI/Web/src/app/_models/kavitaplus/license-info.ts new file mode 100644 index 000000000..4a724b3ff --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/license-info.ts @@ -0,0 +1,9 @@ +export interface LicenseInfo { + expirationDate: string; + isActive: boolean; + isCancelled: boolean; + isValidVersion: boolean; + registeredEmail: string; + totalMonthsSubbed: number; + hasLicense: boolean; +} diff --git a/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts new file mode 100644 index 000000000..05a4041c8 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts @@ -0,0 +1,8 @@ +import {MatchStateOption} from "./match-state-option"; +import {LibraryType} from "../library/library"; + +export interface ManageMatchFilter { + matchStateOption: MatchStateOption; + libraryType: LibraryType | -1; + searchTerm: string; +} diff --git a/UI/Web/src/app/_models/kavitaplus/manage-match-series.ts b/UI/Web/src/app/_models/kavitaplus/manage-match-series.ts new file mode 100644 index 000000000..4138279e6 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/manage-match-series.ts @@ -0,0 +1,7 @@ +import {Series} from "../series"; + +export interface ManageMatchSeries { + series: Series; + isMatched: boolean; + validUntilUtc: string; +} diff --git a/UI/Web/src/app/_models/kavitaplus/match-state-option.ts b/UI/Web/src/app/_models/kavitaplus/match-state-option.ts new file mode 100644 index 000000000..a52c5efad --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/match-state-option.ts @@ -0,0 +1,11 @@ +export enum MatchStateOption { + All = 0, + Matched = 1, + NotMatched = 2, + Error = 3, + DontMatch = 4 +} + +export const allMatchStates = [ + MatchStateOption.Matched, MatchStateOption.NotMatched, MatchStateOption.Error, MatchStateOption.DontMatch +]; diff --git a/UI/Web/src/app/_models/kavitaplus/user-token-info.ts b/UI/Web/src/app/_models/kavitaplus/user-token-info.ts new file mode 100644 index 000000000..1dcab9c91 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/user-token-info.ts @@ -0,0 +1,8 @@ +export interface UserTokenInfo { + userId: number; + username: string; + isAniListTokenSet: boolean; + aniListValidUntilUtc: string; + isAniListTokenValid: boolean; + isMalTokenSet: boolean; +} diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index f834f23b1..bcbf9b447 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -1,5 +1,4 @@ import {FileTypeGroup} from "./file-type-group.enum"; -import {IHasCover} from "../common/i-has-cover"; export enum LibraryType { Manga = 0, @@ -7,9 +6,16 @@ export enum LibraryType { Book = 2, Images = 3, LightNovel = 4, + /** + * Comic (Legacy) + */ ComicVine = 5 } +export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images]; +export const allKavitaPlusMetadataApplicableTypes = [LibraryType.Manga, LibraryType.LightNovel, LibraryType.ComicVine, LibraryType.Comic]; +export const allKavitaPlusScrobbleEligibleTypes = [LibraryType.Manga, LibraryType.LightNovel]; + export interface Library { id: number; name: string; @@ -24,6 +30,9 @@ export interface Library { manageCollections: boolean; manageReadingLists: boolean; allowScrobbling: boolean; + allowMetadataMatching: boolean; + enableMetadata: boolean; + removePrefixForSortName: boolean; collapseSeriesRelationships: boolean; libraryFileTypes: Array; excludePatterns: Array; diff --git a/UI/Web/src/app/_models/metadata/browse/browse-genre.ts b/UI/Web/src/app/_models/metadata/browse/browse-genre.ts new file mode 100644 index 000000000..e7bb0d915 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/browse/browse-genre.ts @@ -0,0 +1,6 @@ +import {Genre} from "../genre"; + +export interface BrowseGenre extends Genre { + seriesCount: number; + chapterCount: number; +} diff --git a/UI/Web/src/app/_models/person/browse-person.ts b/UI/Web/src/app/_models/metadata/browse/browse-person.ts similarity index 52% rename from UI/Web/src/app/_models/person/browse-person.ts rename to UI/Web/src/app/_models/metadata/browse/browse-person.ts index aeddac7cd..886f9455b 100644 --- a/UI/Web/src/app/_models/person/browse-person.ts +++ b/UI/Web/src/app/_models/metadata/browse/browse-person.ts @@ -1,6 +1,6 @@ -import {Person} from "../metadata/person"; +import {Person} from "../person"; export interface BrowsePerson extends Person { seriesCount: number; - issueCount: number; + chapterCount: number; } diff --git a/UI/Web/src/app/_models/metadata/browse/browse-tag.ts b/UI/Web/src/app/_models/metadata/browse/browse-tag.ts new file mode 100644 index 000000000..4d87370ee --- /dev/null +++ b/UI/Web/src/app/_models/metadata/browse/browse-tag.ts @@ -0,0 +1,6 @@ +import {Tag} from "../../tag"; + +export interface BrowseTag extends Tag { + seriesCount: number; + chapterCount: number; +} diff --git a/UI/Web/src/app/_models/metadata/language.ts b/UI/Web/src/app/_models/metadata/language.ts index e8f606bec..28ab2b598 100644 --- a/UI/Web/src/app/_models/metadata/language.ts +++ b/UI/Web/src/app/_models/metadata/language.ts @@ -3,3 +3,13 @@ export interface Language { title: string; } +export interface KavitaLocale { + /** + * isoCode aka what maps to the file on disk and what transloco loads + */ + fileName: string; + renderName: string; + translationCompletion: number; + isRtL: boolean; + hash: string; +} diff --git a/UI/Web/src/app/_models/metadata/person.ts b/UI/Web/src/app/_models/metadata/person.ts index 47c7590b3..efc8df914 100644 --- a/UI/Web/src/app/_models/metadata/person.ts +++ b/UI/Web/src/app/_models/metadata/person.ts @@ -1,6 +1,7 @@ +import {IHasCover} from "../common/i-has-cover"; + export enum PersonRole { Other = 1, - Artist = 2, Writer = 3, Penciller = 4, Inker = 5, @@ -16,16 +17,36 @@ export enum PersonRole { Location = 15 } -export interface Person { +export interface Person extends IHasCover { id: number; name: string; description: string; + aliases: Array; coverImage?: string; coverImageLocked: boolean; malId?: number; aniListId?: number; hardcoverId?: string; asin?: string; - primaryColor?: string; - secondaryColor?: string; + primaryColor: string; + secondaryColor: string; } + +/** + * Excludes Other as it's not in use + */ +export const allPeopleRoles = [ + PersonRole.Writer, + PersonRole.Penciller, + PersonRole.Inker, + PersonRole.Colorist, + PersonRole.Letterer, + PersonRole.CoverArtist, + PersonRole.Editor, + PersonRole.Publisher, + PersonRole.Character, + PersonRole.Translator, + PersonRole.Imprint, + PersonRole.Team, + PersonRole.Location +] diff --git a/UI/Web/src/app/_models/metadata/series-filter.ts b/UI/Web/src/app/_models/metadata/series-filter.ts index bfaee4f3f..7875732b7 100644 --- a/UI/Web/src/app/_models/metadata/series-filter.ts +++ b/UI/Web/src/app/_models/metadata/series-filter.ts @@ -1,6 +1,5 @@ -import { MangaFormat } from "../manga-format"; -import { SeriesFilterV2 } from "./v2/series-filter-v2"; -import {FilterField} from "./v2/filter-field"; +import {MangaFormat} from "../manga-format"; +import {FilterV2} from "./v2/filter-v2"; export interface FilterItem { title: string; @@ -8,10 +7,6 @@ export interface FilterItem { selected: boolean; } -export interface SortOptions { - sortField: SortField; - isAscending: boolean; -} export enum SortField { SortName = 1, @@ -28,35 +23,35 @@ export enum SortField { Random = 9 } -export const allSortFields = Object.keys(SortField) +export const allSeriesSortFields = Object.keys(SortField) .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) .map(key => parseInt(key, 10)) as SortField[]; export const mangaFormatFilters = [ { - title: 'Images', + title: 'images', value: MangaFormat.IMAGE, selected: false }, { - title: 'EPUB', + title: 'epub', value: MangaFormat.EPUB, selected: false }, { - title: 'PDF', + title: 'pdf', value: MangaFormat.PDF, selected: false }, { - title: 'ARCHIVE', + title: 'archive', value: MangaFormat.ARCHIVE, selected: false } ]; -export interface FilterEvent { - filterV2: SeriesFilterV2; +export interface FilterEvent { + filterV2: FilterV2; isFirst: boolean; } diff --git a/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts b/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts new file mode 100644 index 000000000..bb5edc9ce --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts @@ -0,0 +1,8 @@ +import {PersonRole} from "../person"; +import {PersonSortOptions} from "./sort-options"; + +export interface BrowsePersonFilter { + roles: Array; + query?: string; + sortOptions?: PersonSortOptions; +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-field.ts b/UI/Web/src/app/_models/metadata/v2/filter-field.ts index 2adebdf6d..eeb8c7853 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-field.ts @@ -34,7 +34,8 @@ export enum FilterField AverageRating = 28, Imprint = 29, Team = 30, - Location = 31 + Location = 31, + ReadLast = 32 } @@ -47,7 +48,7 @@ const enumArray = Object.keys(FilterField) enumArray.sort((a, b) => a.value.localeCompare(b.value)); -export const allFields = enumArray +export const allSeriesFilterFields = enumArray .map(key => parseInt(key.key, 10))as FilterField[]; export const allPeople = [ @@ -65,7 +66,6 @@ export const allPeople = [ export const personRoleForFilterField = (role: PersonRole) => { switch (role) { - case PersonRole.Artist: return FilterField.CoverArtist; case PersonRole.Character: return FilterField.Characters; case PersonRole.Colorist: return FilterField.Colorist; case PersonRole.CoverArtist: return FilterField.CoverArtist; diff --git a/UI/Web/src/app/_models/metadata/v2/filter-statement.ts b/UI/Web/src/app/_models/metadata/v2/filter-statement.ts index d031927a2..b14fe564d 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-statement.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-statement.ts @@ -1,8 +1,7 @@ -import { FilterComparison } from "./filter-comparison"; -import { FilterField } from "./filter-field"; +import {FilterComparison} from "./filter-comparison"; -export interface FilterStatement { +export interface FilterStatement { comparison: FilterComparison; - field: FilterField; + field: T; value: string; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-v2.ts b/UI/Web/src/app/_models/metadata/v2/filter-v2.ts new file mode 100644 index 000000000..77c064450 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/filter-v2.ts @@ -0,0 +1,11 @@ +import {FilterStatement} from "./filter-statement"; +import {FilterCombination} from "./filter-combination"; +import {SortOptions} from "./sort-options"; + +export interface FilterV2 { + name?: string; + statements: Array>; + combination: FilterCombination; + sortOptions?: SortOptions; + limitTo: number; +} diff --git a/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts b/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts new file mode 100644 index 000000000..6bfb5a0c1 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts @@ -0,0 +1,12 @@ +export enum PersonFilterField { + Role = 1, + Name = 2, + SeriesCount = 3, + ChapterCount = 4, +} + + +export const allPersonFilterFields = Object.keys(PersonFilterField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as PersonFilterField[]; + diff --git a/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts b/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts new file mode 100644 index 000000000..6bcb66925 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts @@ -0,0 +1,9 @@ +export enum PersonSortField { + Name = 1, + SeriesCount = 2, + ChapterCount = 3 +} + +export const allPersonSortFields = Object.keys(PersonSortField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as PersonSortField[]; diff --git a/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts b/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts deleted file mode 100644 index c13244644..000000000 --- a/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SortOptions } from "../series-filter"; -import {FilterStatement} from "./filter-statement"; -import {FilterCombination} from "./filter-combination"; - -export interface SeriesFilterV2 { - name?: string; - statements: Array; - combination: FilterCombination; - sortOptions?: SortOptions; - limitTo: number; -} diff --git a/UI/Web/src/app/_models/metadata/v2/sort-options.ts b/UI/Web/src/app/_models/metadata/v2/sort-options.ts new file mode 100644 index 000000000..ed68d6b9d --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/sort-options.ts @@ -0,0 +1,17 @@ +import {PersonSortField} from "./person-sort-field"; + +/** + * Series-based Sort options + */ +export interface SortOptions { + sortField: TSort; + isAscending: boolean; +} + +/** + * Person-based Sort Options + */ +export interface PersonSortOptions { + sortField: PersonSortField; + isAscending: boolean; +} diff --git a/UI/Web/src/app/_models/preferences/book-theme.ts b/UI/Web/src/app/_models/preferences/book-theme.ts index b6e37f6e4..cb321c110 100644 --- a/UI/Web/src/app/_models/preferences/book-theme.ts +++ b/UI/Web/src/app/_models/preferences/book-theme.ts @@ -1,7 +1,7 @@ -import { ThemeProvider } from "./site-theme"; +import {ThemeProvider} from "./site-theme"; /** - * Theme for the the book reader contents + * Theme for the book reader contents */ export interface BookTheme { name: string; diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index b6e364056..886c570e2 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,68 +1,20 @@ - -import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; -import { BookPageLayoutMode } from '../readers/book-page-layout-mode'; -import { PageLayoutMode } from '../page-layout-mode'; -import { PageSplitOption } from './page-split-option'; -import { ReaderMode } from './reader-mode'; -import { ReadingDirection } from './reading-direction'; -import { ScalingOption } from './scaling-option'; -import { SiteTheme } from './site-theme'; -import {WritingStyle} from "./writing-style"; -import {PdfTheme} from "./pdf-theme"; -import {PdfScrollMode} from "./pdf-scroll-mode"; -import {PdfLayoutMode} from "./pdf-layout-mode"; -import {PdfSpreadMode} from "./pdf-spread-mode"; +import {PageLayoutMode} from '../page-layout-mode'; +import {SiteTheme} from './site-theme'; export interface Preferences { - // Manga Reader - readingDirection: ReadingDirection; - scalingOption: ScalingOption; - pageSplitOption: PageSplitOption; - readerMode: ReaderMode; - autoCloseMenu: boolean; - layoutMode: LayoutMode; - backgroundColor: string; - showScreenHints: boolean; - emulateBook: boolean; - swipeToPaginate: boolean; - // Book Reader - bookReaderMargin: number; - bookReaderLineSpacing: number; - bookReaderFontSize: number; - bookReaderFontFamily: string; - bookReaderTapToPaginate: boolean; - bookReaderReadingDirection: ReadingDirection; - bookReaderWritingStyle: WritingStyle; - bookReaderThemeName: string; - bookReaderLayoutMode: BookPageLayoutMode; - bookReaderImmersiveMode: boolean; + // Global + theme: SiteTheme; + globalPageLayoutMode: PageLayoutMode; + blurUnreadSummaries: boolean; + promptForDownloadSize: boolean; + noTransitions: boolean; + collapseSeriesRelationships: boolean; + shareReviews: boolean; + locale: string; - // PDF Reader - pdfTheme: PdfTheme; - pdfScrollMode: PdfScrollMode; - pdfSpreadMode: PdfSpreadMode; - - // Global - theme: SiteTheme; - globalPageLayoutMode: PageLayoutMode; - blurUnreadSummaries: boolean; - promptForDownloadSize: boolean; - noTransitions: boolean; - collapseSeriesRelationships: boolean; - shareReviews: boolean; - locale: string; + // Kavita+ + aniListScrobblingEnabled: boolean; + wantToReadSync: boolean; } -export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}]; -export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horizontal}, {text: 'vertical', value: WritingStyle.Vertical}]; -export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}]; -export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}]; -export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}]; -export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover} -export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}]; -export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}]; -export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}]; -export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}]; -export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}]; -export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}]; diff --git a/UI/Web/src/app/_models/preferences/reading-profiles.ts b/UI/Web/src/app/_models/preferences/reading-profiles.ts new file mode 100644 index 000000000..dad02946f --- /dev/null +++ b/UI/Web/src/app/_models/preferences/reading-profiles.ts @@ -0,0 +1,80 @@ +import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode'; +import {BookPageLayoutMode} from '../readers/book-page-layout-mode'; +import {PageLayoutMode} from '../page-layout-mode'; +import {PageSplitOption} from './page-split-option'; +import {ReaderMode} from './reader-mode'; +import {ReadingDirection} from './reading-direction'; +import {ScalingOption} from './scaling-option'; +import {WritingStyle} from "./writing-style"; +import {PdfTheme} from "./pdf-theme"; +import {PdfScrollMode} from "./pdf-scroll-mode"; +import {PdfLayoutMode} from "./pdf-layout-mode"; +import {PdfSpreadMode} from "./pdf-spread-mode"; +import {Series} from "../series"; +import {Library} from "../library/library"; +import {UserBreakpoint} from "../../shared/_services/utility.service"; + +export enum ReadingProfileKind { + Default = 0, + User = 1, + Implicit = 2, +} + +export interface ReadingProfile { + + id: number; + name: string; + normalizedName: string; + kind: ReadingProfileKind; + + // Manga Reader + readingDirection: ReadingDirection; + scalingOption: ScalingOption; + pageSplitOption: PageSplitOption; + readerMode: ReaderMode; + autoCloseMenu: boolean; + layoutMode: LayoutMode; + backgroundColor: string; + showScreenHints: boolean; + emulateBook: boolean; + swipeToPaginate: boolean; + allowAutomaticWebtoonReaderDetection: boolean; + widthOverride?: number; + disableWidthOverride: UserBreakpoint; + + // Book Reader + bookReaderMargin: number; + bookReaderLineSpacing: number; + bookReaderFontSize: number; + bookReaderFontFamily: string; + bookReaderTapToPaginate: boolean; + bookReaderReadingDirection: ReadingDirection; + bookReaderWritingStyle: WritingStyle; + bookReaderThemeName: string; + bookReaderLayoutMode: BookPageLayoutMode; + bookReaderImmersiveMode: boolean; + + // PDF Reader + pdfTheme: PdfTheme; + pdfScrollMode: PdfScrollMode; + pdfSpreadMode: PdfSpreadMode; + + // relations + seriesIds: number[]; + libraryIds: number[]; + +} + +export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}]; +export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horizontal}, {text: 'vertical', value: WritingStyle.Vertical}]; +export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}]; +export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}]; +export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}]; +export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover} +export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}]; +export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}]; +export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}]; +export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}]; +export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}]; +export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}]; +export const breakPoints = [UserBreakpoint.Never, UserBreakpoint.Mobile, UserBreakpoint.Tablet, UserBreakpoint.Desktop] diff --git a/UI/Web/src/app/_models/rating.ts b/UI/Web/src/app/_models/rating.ts index a4c4b79ed..7132706f9 100644 --- a/UI/Web/src/app/_models/rating.ts +++ b/UI/Web/src/app/_models/rating.ts @@ -1,9 +1,15 @@ import {ScrobbleProvider} from "../_services/scrobbling.service"; +export enum RatingAuthority { + User = 0, + Critic = 1, +} + export interface Rating { averageScore: number; meanScore: number; favoriteCount: number; provider: ScrobbleProvider; providerUrl: string | undefined; + authority: RatingAuthority; } diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index 123fb3b85..646360153 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -1,41 +1,57 @@ -import { LibraryType } from "./library/library"; -import { MangaFormat } from "./manga-format"; +import {LibraryType} from "./library/library"; +import {MangaFormat} from "./manga-format"; import {IHasCover} from "./common/i-has-cover"; +import {AgeRating} from "./metadata/age-rating"; +import {IHasReadingTime} from "./common/i-has-reading-time"; +import {IHasCast} from "./common/i-has-cast"; export interface ReadingListItem { - pagesRead: number; - pagesTotal: number; - seriesName: string; - seriesFormat: MangaFormat; - seriesId: number; - chapterId: number; - order: number; - chapterNumber: string; - volumeNumber: string; - libraryId: number; - id: number; - releaseDate: string; - title: string; - libraryType: LibraryType; - libraryName: string; - summary?: string; + pagesRead: number; + pagesTotal: number; + seriesName: string; + seriesFormat: MangaFormat; + seriesId: number; + chapterId: number; + order: number; + chapterNumber: string; + volumeNumber: string; + libraryId: number; + id: number; + releaseDate: string; + title: string; + libraryType: LibraryType; + libraryName: string; + summary?: string; } export interface ReadingList extends IHasCover { - id: number; - title: string; - summary: string; - promoted: boolean; - coverImageLocked: boolean; - items: Array; - /** - * If this is empty or null, the cover image isn't set. Do not use this externally. - */ - coverImage?: string; - primaryColor: string; - secondaryColor: string; - startingYear: number; - startingMonth: number; - endingYear: number; - endingMonth: number; + id: number; + title: string; + summary: string; + promoted: boolean; + coverImageLocked: boolean; + items: Array; + /** + * If this is empty or null, the cover image isn't set. Do not use this externally. + */ + coverImage?: string; + primaryColor: string; + secondaryColor: string; + startingYear: number; + startingMonth: number; + endingYear: number; + endingMonth: number; + itemCount: number; + ageRating: AgeRating; } + +export interface ReadingListInfo extends IHasReadingTime, IHasReadingTime { + pages: number; + wordCount: number; + isAllEpub: boolean; + minHoursToRead: number; + maxHoursToRead: number; + avgHoursToRead: number; +} + +export interface ReadingListCast extends IHasCast {} diff --git a/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts b/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts index 102cf89d1..c0ea95d64 100644 --- a/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts +++ b/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts @@ -4,7 +4,8 @@ export enum ScrobbleEventSortField { LastModified = 2, Type= 3, Series = 4, - IsProcessed = 5 + IsProcessed = 5, + ScrobbleEvent = 6 } export interface ScrobbleEventFilter { diff --git a/UI/Web/src/app/_models/scrobbling/scrobble-event.ts b/UI/Web/src/app/_models/scrobbling/scrobble-event.ts index 48a75afda..7db1ceeaa 100644 --- a/UI/Web/src/app/_models/scrobbling/scrobble-event.ts +++ b/UI/Web/src/app/_models/scrobbling/scrobble-event.ts @@ -7,6 +7,7 @@ export enum ScrobbleEventType { } export interface ScrobbleEvent { + id: number; seriesName: string; seriesId: number; libraryId: number; diff --git a/UI/Web/src/app/_models/series-detail/external-series-detail.ts b/UI/Web/src/app/_models/series-detail/external-series-detail.ts index 85d89c760..db25782ca 100644 --- a/UI/Web/src/app/_models/series-detail/external-series-detail.ts +++ b/UI/Web/src/app/_models/series-detail/external-series-detail.ts @@ -27,8 +27,9 @@ export interface MetadataTagDto { export interface ExternalSeriesDetail { name: string; - aniListId?: number; - malId?: number; + aniListId?: number | null; + malId?: number | null; + cbrId?: number | null; synonyms: Array; plusMediaFormat: PlusMediaFormat; siteUrl?: string; @@ -37,6 +38,11 @@ export interface ExternalSeriesDetail { summary?: string; volumeCount?: number; chapterCount?: number; + /** + * These are duplicated with volumeCount based on where it's being invoked. + */ + volumes?: number; + chapters?: number; staff: Array; tags: Array; provider: ScrobbleProvider; diff --git a/UI/Web/src/app/_models/series-detail/external-series-match.ts b/UI/Web/src/app/_models/series-detail/external-series-match.ts new file mode 100644 index 000000000..28afea18a --- /dev/null +++ b/UI/Web/src/app/_models/series-detail/external-series-match.ts @@ -0,0 +1,6 @@ +import {ExternalSeriesDetail} from "./external-series-detail"; + +export interface ExternalSeriesMatch { + series: ExternalSeriesDetail; + matchRating: number; +} diff --git a/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts b/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts index f94ac569b..805a71178 100644 --- a/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts +++ b/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts @@ -1,6 +1,5 @@ -export interface HourEstimateRange{ +export interface HourEstimateRange { minHours: number; maxHours: number; avgHours: number; - //hasProgress: boolean; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index 8d4f773bb..29d4aed7f 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -5,70 +5,78 @@ import {IHasReadingTime} from "./common/i-has-reading-time"; import {IHasProgress} from "./common/i-has-progress"; export interface Series extends IHasCover, IHasReadingTime, IHasProgress { - id: number; - name: string; - /** - * This is not shown to user - */ - originalName: string; - localizedName: string; - sortName: string; - coverImageLocked: boolean; - sortNameLocked: boolean; - localizedNameLocked: boolean; - nameLocked: boolean; - volumes: Volume[]; - /** - * Total pages in series - */ - pages: number; - /** - * Total pages the logged in user has read - */ - pagesRead: number; - /** - * User's rating (0-5) - */ - userRating: number; - hasUserRated: boolean; - libraryId: number; - /** - * DateTime the entity was created - */ - created: string; - /** - * Format of the Series - */ - format: MangaFormat; - /** - * DateTime that represents last time the logged in user read this series - */ - latestReadDate: string; - /** - * DateTime representing last time a chapter was added to the Series - */ - lastChapterAdded: string; - /** - * DateTime representing last time the series folder was scanned - */ - lastFolderScanned: string; - /** - * Number of words in the series - */ - wordCount: number; - minHoursToRead: number; - maxHoursToRead: number; - avgHoursToRead: number; - /** - * Highest level folder containing this series - */ - folderPath: string; - lowestFolderPath: string; - /** - * This is currently only used on Series detail page for recommendations - */ - summary?: string; - coverImage?: string; - primaryColor: string; - secondaryColor: string; + id: number; + name: string; + /** + * This is not shown to user + */ + originalName: string; + localizedName: string; + sortName: string; + coverImageLocked: boolean; + sortNameLocked: boolean; + localizedNameLocked: boolean; + nameLocked: boolean; + volumes: Volume[]; + /** + * Total pages in series + */ + pages: number; + /** + * Total pages the logged in user has read + */ + pagesRead: number; + /** + * User's rating (0-5) + */ + userRating: number; + hasUserRated: boolean; + libraryId: number; + /** + * DateTime the entity was created + */ + created: string; + /** + * Format of the Series + */ + format: MangaFormat; + /** + * DateTime that represents last time the logged in user read this series + */ + latestReadDate: string; + /** + * DateTime representing last time a chapter was added to the Series + */ + lastChapterAdded: string; + /** + * DateTime representing last time the series folder was scanned + */ + lastFolderScanned: string; + /** + * Number of words in the series + */ + wordCount: number; + minHoursToRead: number; + maxHoursToRead: number; + avgHoursToRead: number; + /** + * Highest level folder containing this series + */ + folderPath: string; + lowestFolderPath: string; + /** + * This is currently only used on Series detail page for recommendations + */ + summary?: string; + coverImage?: string; + primaryColor: string; + secondaryColor: string; + /** + * Kavita+ only. Will not perform any matching from Kavita+ + */ + dontMatch: boolean; + /** + * Kavita+ only. Did this series not match and won't without manual match + */ + isBlacklisted: boolean; } diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index e5a74dbcf..c94a9485d 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -1,14 +1,16 @@ -import { AgeRestriction } from './metadata/age-restriction'; -import { Preferences } from './preferences/preferences'; +import {AgeRestriction} from './metadata/age-restriction'; +import {Preferences} from './preferences/preferences'; // This interface is only used for login and storing/retrieving JWT from local storage export interface User { - username: string; - token: string; - refreshToken: string; - roles: string[]; - preferences: Preferences; - apiKey: string; - email: string; - ageRestriction: AgeRestriction; + username: string; + token: string; + refreshToken: string; + roles: string[]; + preferences: Preferences; + apiKey: string; + email: string; + ageRestriction: AgeRestriction; + hasRunScrobbleEventGeneration: boolean; + scrobbleEventGenerationRan: string; // datetime } diff --git a/UI/Web/src/app/_models/wiki.ts b/UI/Web/src/app/_models/wiki.ts index 381a38638..a01267cf3 100644 --- a/UI/Web/src/app/_models/wiki.ts +++ b/UI/Web/src/app/_models/wiki.ts @@ -6,9 +6,9 @@ export enum WikiLink { SeriesRelationships = 'https://wiki.kavitareader.com/guides/features/relationships', Bookmarks = 'https://wiki.kavitareader.com/guides/features/bookmarks', DataCollection = 'https://wiki.kavitareader.com/troubleshooting/faq#q-does-kavita-collect-any-data-on-me', - MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/media#media-issues', + MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/mediaissues/', KavitaPlusDiscordId = 'https://wiki.kavitareader.com/guides/admin-settings/kavita+#discord-id', - KavitaPlus = 'https://wiki.kavitareader.com/guides/admin-settings/kavita+', + KavitaPlus = 'https://wiki.kavitareader.com/kavita+', KavitaPlusFAQ = 'https://wiki.kavitareader.com/kavita+/faq', ReadingListCBL = 'https://wiki.kavitareader.com/guides/features/readinglists#creating-a-reading-list-via-cbl', Donation = 'https://wiki.kavitareader.com/donating', @@ -19,6 +19,7 @@ export enum WikiLink { Library = 'https://wiki.kavitareader.com/guides/admin-settings/libraries', UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native', UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker', - OpdsClients = 'https://wiki.kavitareader.com/guides/opds#opds-capable-clients', - Guides = 'https://wiki.kavitareader.com/guides' + OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients', + Guides = 'https://wiki.kavitareader.com/guides', + ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/", } diff --git a/UI/Web/src/app/_pipes/age-rating.pipe.ts b/UI/Web/src/app/_pipes/age-rating.pipe.ts index 15554cf05..f99a77f72 100644 --- a/UI/Web/src/app/_pipes/age-rating.pipe.ts +++ b/UI/Web/src/app/_pipes/age-rating.pipe.ts @@ -1,22 +1,22 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; -import { Observable, of } from 'rxjs'; -import { AgeRating } from '../_models/metadata/age-rating'; -import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; +import {AgeRating} from '../_models/metadata/age-rating'; +import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'ageRating', - standalone: true + standalone: true, + pure: true }) export class AgeRatingPipe implements PipeTransform { - translocoService = inject(TranslocoService); + private readonly translocoService = inject(TranslocoService); - transform(value: AgeRating | AgeRatingDto | undefined): Observable { - if (value === undefined || value === null) return of(this.translocoService.translate('age-rating-pipe.unknown') as string); + transform(value: AgeRating | AgeRatingDto | undefined): string { + if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown'); if (value.hasOwnProperty('title')) { - return of((value as AgeRatingDto).title); + return (value as AgeRatingDto).title; } switch (value) { @@ -54,7 +54,7 @@ export class AgeRatingPipe implements PipeTransform { return this.translocoService.translate('age-rating-pipe.r18-plus'); } - return of(this.translocoService.translate('age-rating-pipe.unknown') as string); + return this.translocoService.translate('age-rating-pipe.unknown'); } } diff --git a/UI/Web/src/app/_pipes/breakpoint.pipe.ts b/UI/Web/src/app/_pipes/breakpoint.pipe.ts new file mode 100644 index 000000000..1897b773c --- /dev/null +++ b/UI/Web/src/app/_pipes/breakpoint.pipe.ts @@ -0,0 +1,25 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {UserBreakpoint} from "../shared/_services/utility.service"; + +@Pipe({ + name: 'breakpoint' +}) +export class BreakpointPipe implements PipeTransform { + + transform(value: UserBreakpoint): string { + const v = parseInt(value + '', 10) as UserBreakpoint; + switch (v) { + case UserBreakpoint.Never: + return translate('breakpoint-pipe.never'); + case UserBreakpoint.Mobile: + return translate('breakpoint-pipe.mobile'); + case UserBreakpoint.Tablet: + return translate('breakpoint-pipe.tablet'); + case UserBreakpoint.Desktop: + return translate('breakpoint-pipe.desktop'); + } + throw new Error("unknown breakpoint value: " + value); + } + +} diff --git a/UI/Web/src/app/_pipes/browse-title.pipe.ts b/UI/Web/src/app/_pipes/browse-title.pipe.ts new file mode 100644 index 000000000..0495e8b8a --- /dev/null +++ b/UI/Web/src/app/_pipes/browse-title.pipe.ts @@ -0,0 +1,78 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {FilterField} from "../_models/metadata/v2/filter-field"; +import {translate} from "@jsverse/transloco"; + +/** + * Responsible for taking a filter field and value (as a string) and translating into a "Browse X" heading for All Series page + * Example: Genre & "Action" -> Browse Action + * Example: Artist & "Joe Shmo" -> Browse Joe Shmo Works + */ +@Pipe({ + name: 'browseTitle' +}) +export class BrowseTitlePipe implements PipeTransform { + + transform(field: FilterField, value: string): string { + switch (field) { + case FilterField.PublicationStatus: + return translate('browse-title-pipe.publication-status', {value}); + case FilterField.AgeRating: + return translate('browse-title-pipe.age-rating', {value}); + case FilterField.UserRating: + return translate('browse-title-pipe.user-rating', {value}); + case FilterField.Tags: + return translate('browse-title-pipe.tag', {value}); + case FilterField.Translators: + return translate('browse-title-pipe.translator', {value}); + case FilterField.Characters: + return translate('browse-title-pipe.character', {value}); + case FilterField.Publisher: + return translate('browse-title-pipe.publisher', {value}); + case FilterField.Editor: + return translate('browse-title-pipe.editor', {value}); + case FilterField.CoverArtist: + return translate('browse-title-pipe.artist', {value}); + case FilterField.Letterer: + return translate('browse-title-pipe.letterer', {value}); + case FilterField.Colorist: + return translate('browse-title-pipe.colorist', {value}); + case FilterField.Inker: + return translate('browse-title-pipe.inker', {value}); + case FilterField.Penciller: + return translate('browse-title-pipe.penciller', {value}); + case FilterField.Writers: + return translate('browse-title-pipe.writer', {value}); + case FilterField.Genres: + return translate('browse-title-pipe.genre', {value}); + case FilterField.Libraries: + return translate('browse-title-pipe.library', {value}); + case FilterField.Formats: + return translate('browse-title-pipe.format', {value}); + case FilterField.ReleaseYear: + return translate('browse-title-pipe.release-year', {value}); + case FilterField.Imprint: + return translate('browse-title-pipe.imprint', {value}); + case FilterField.Team: + return translate('browse-title-pipe.team', {value}); + case FilterField.Location: + return translate('browse-title-pipe.location', {value}); + + // These have no natural links in the app to demand a richer title experience + case FilterField.Languages: + case FilterField.CollectionTags: + case FilterField.ReadProgress: + case FilterField.ReadTime: + case FilterField.Path: + case FilterField.FilePath: + case FilterField.WantToRead: + case FilterField.ReadingDate: + case FilterField.AverageRating: + case FilterField.ReadLast: + case FilterField.Summary: + case FilterField.SeriesName: + default: + return ''; + } + } + +} diff --git a/UI/Web/src/app/_pipes/confirm-translate.pipe.ts b/UI/Web/src/app/_pipes/confirm-translate.pipe.ts new file mode 100644 index 000000000..008f28849 --- /dev/null +++ b/UI/Web/src/app/_pipes/confirm-translate.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { translate } from '@jsverse/transloco'; + +@Pipe({ + name: 'confirmTranslate', + standalone: true +}) +export class ConfirmTranslatePipe implements PipeTransform { + + transform(value: string | undefined | null): string | undefined | null { + if (!value) return value; + + if (value.startsWith('confirm.')) { + return translate(value); + } + + return value; + } + +} diff --git a/UI/Web/src/app/_pipes/encode-format.pipe.ts b/UI/Web/src/app/_pipes/encode-format.pipe.ts index 9485f80f8..f082c0495 100644 --- a/UI/Web/src/app/_pipes/encode-format.pipe.ts +++ b/UI/Web/src/app/_pipes/encode-format.pipe.ts @@ -1,4 +1,4 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {EncodeFormat} from "../admin/_models/encode-format"; @Pipe({ diff --git a/UI/Web/src/app/_pipes/filter-field.pipe.ts b/UI/Web/src/app/_pipes/filter-field.pipe.ts index acd0993f7..056d99f53 100644 --- a/UI/Web/src/app/_pipes/filter-field.pipe.ts +++ b/UI/Web/src/app/_pipes/filter-field.pipe.ts @@ -72,6 +72,8 @@ export class FilterFieldPipe implements PipeTransform { return translate('filter-field-pipe.want-to-read'); case FilterField.ReadingDate: return translate('filter-field-pipe.read-date'); + case FilterField.ReadLast: + return translate('filter-field-pipe.read-last'); case FilterField.AverageRating: return translate('filter-field-pipe.average-rating'); default: diff --git a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts new file mode 100644 index 000000000..f342c0034 --- /dev/null +++ b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts @@ -0,0 +1,108 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {FilterField} from "../_models/metadata/v2/filter-field"; +import {translate} from "@jsverse/transloco"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; + +@Pipe({ + name: 'genericFilterField' +}) +export class GenericFilterFieldPipe implements PipeTransform { + + transform(value: T, entityType: ValidFilterEntity): string { + + switch (entityType) { + case "series": + return this.translateFilterField(value as FilterField); + case "person": + return this.translatePersonFilterField(value as PersonFilterField); + } + } + + private translatePersonFilterField(value: PersonFilterField) { + switch (value) { + case PersonFilterField.Role: + return translate('generic-filter-field-pipe.person-role'); + case PersonFilterField.Name: + return translate('generic-filter-field-pipe.person-name'); + case PersonFilterField.SeriesCount: + return translate('generic-filter-field-pipe.person-series-count'); + case PersonFilterField.ChapterCount: + return translate('generic-filter-field-pipe.person-chapter-count'); + } + } + + private translateFilterField(value: FilterField) { + switch (value) { + case FilterField.AgeRating: + return translate('filter-field-pipe.age-rating'); + case FilterField.Characters: + return translate('filter-field-pipe.characters'); + case FilterField.CollectionTags: + return translate('filter-field-pipe.collection-tags'); + case FilterField.Colorist: + return translate('filter-field-pipe.colorist'); + case FilterField.CoverArtist: + return translate('filter-field-pipe.cover-artist'); + case FilterField.Editor: + return translate('filter-field-pipe.editor'); + case FilterField.Formats: + return translate('filter-field-pipe.formats'); + case FilterField.Genres: + return translate('filter-field-pipe.genres'); + case FilterField.Inker: + return translate('filter-field-pipe.inker'); + case FilterField.Imprint: + return translate('filter-field-pipe.imprint'); + case FilterField.Team: + return translate('filter-field-pipe.team'); + case FilterField.Location: + return translate('filter-field-pipe.location'); + case FilterField.Languages: + return translate('filter-field-pipe.languages'); + case FilterField.Libraries: + return translate('filter-field-pipe.libraries'); + case FilterField.Letterer: + return translate('filter-field-pipe.letterer'); + case FilterField.PublicationStatus: + return translate('filter-field-pipe.publication-status'); + case FilterField.Penciller: + return translate('filter-field-pipe.penciller'); + case FilterField.Publisher: + return translate('filter-field-pipe.publisher'); + case FilterField.ReadProgress: + return translate('filter-field-pipe.read-progress'); + case FilterField.ReadTime: + return translate('filter-field-pipe.read-time'); + case FilterField.ReleaseYear: + return translate('filter-field-pipe.release-year'); + case FilterField.SeriesName: + return translate('filter-field-pipe.series-name'); + case FilterField.Summary: + return translate('filter-field-pipe.summary'); + case FilterField.Tags: + return translate('filter-field-pipe.tags'); + case FilterField.Translators: + return translate('filter-field-pipe.translators'); + case FilterField.UserRating: + return translate('filter-field-pipe.user-rating'); + case FilterField.Writers: + return translate('filter-field-pipe.writers'); + case FilterField.Path: + return translate('filter-field-pipe.path'); + case FilterField.FilePath: + return translate('filter-field-pipe.file-path'); + case FilterField.WantToRead: + return translate('filter-field-pipe.want-to-read'); + case FilterField.ReadingDate: + return translate('filter-field-pipe.read-date'); + case FilterField.ReadLast: + return translate('filter-field-pipe.read-last'); + case FilterField.AverageRating: + return translate('filter-field-pipe.average-rating'); + default: + throw new Error(`Invalid FilterField value: ${value}`); + } + } + +} diff --git a/UI/Web/src/app/_pipes/language-name.pipe.ts b/UI/Web/src/app/_pipes/language-name.pipe.ts index be6d954b0..697554bd3 100644 --- a/UI/Web/src/app/_pipes/language-name.pipe.ts +++ b/UI/Web/src/app/_pipes/language-name.pipe.ts @@ -9,16 +9,10 @@ import {shareReplay} from "rxjs/operators"; }) export class LanguageNamePipe implements PipeTransform { - constructor(private metadataService: MetadataService) { - } + constructor(private metadataService: MetadataService) {} transform(isoCode: string): Observable { - // TODO: See if we can speed this up. It rarely changes and is quite heavy to download on each page - return this.metadataService.getAllValidLanguages().pipe(map(lang => { - const l = lang.filter(l => l.isoCode === isoCode); - if (l.length > 0) return l[0].title; - return ''; - }), shareReplay()); + return this.metadataService.getLanguageNameForCode(isoCode).pipe(shareReplay()); } } diff --git a/UI/Web/src/app/_pipes/library-name.pipe.ts b/UI/Web/src/app/_pipes/library-name.pipe.ts new file mode 100644 index 000000000..c34f50166 --- /dev/null +++ b/UI/Web/src/app/_pipes/library-name.pipe.ts @@ -0,0 +1,16 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {LibraryService} from "../_services/library.service"; +import {Observable} from "rxjs"; + +@Pipe({ + name: 'libraryName', + standalone: true +}) +export class LibraryNamePipe implements PipeTransform { + private readonly libraryService = inject(LibraryService); + + transform(libraryId: number): Observable { + return this.libraryService.getLibraryName(libraryId); + } + +} diff --git a/UI/Web/src/app/_pipes/library-type.pipe.ts b/UI/Web/src/app/_pipes/library-type.pipe.ts index 74a62647f..1881b64d5 100644 --- a/UI/Web/src/app/_pipes/library-type.pipe.ts +++ b/UI/Web/src/app/_pipes/library-type.pipe.ts @@ -11,7 +11,7 @@ import {TranslocoService} from "@jsverse/transloco"; }) export class LibraryTypePipe implements PipeTransform { - translocoService = inject(TranslocoService); + private readonly translocoService = inject(TranslocoService); transform(libraryType: LibraryType): string { switch (libraryType) { case LibraryType.Book: diff --git a/UI/Web/src/app/_pipes/log-level.pipe.ts b/UI/Web/src/app/_pipes/log-level.pipe.ts new file mode 100644 index 000000000..1a1c7c19a --- /dev/null +++ b/UI/Web/src/app/_pipes/log-level.pipe.ts @@ -0,0 +1,17 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {translate} from "@jsverse/transloco"; + +/** + * Transforms the log level string into a localized string + */ +@Pipe({ + name: 'logLevel', + standalone: true, + pure: true +}) +export class LogLevelPipe implements PipeTransform { + transform(value: string): string { + return translate('log-level-pipe.' + value.toLowerCase()); + } + +} diff --git a/UI/Web/src/app/_pipes/match-state.pipe.ts b/UI/Web/src/app/_pipes/match-state.pipe.ts new file mode 100644 index 000000000..9f0cb00ae --- /dev/null +++ b/UI/Web/src/app/_pipes/match-state.pipe.ts @@ -0,0 +1,27 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {MatchStateOption} from "../_models/kavitaplus/match-state-option"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'matchStateOption', + standalone: true +}) +export class MatchStateOptionPipe implements PipeTransform { + + transform(value: MatchStateOption): string { + switch (value) { + case MatchStateOption.DontMatch: + return translate('manage-matched-metadata.dont-match-label'); + case MatchStateOption.All: + return translate('manage-matched-metadata.all-status-label'); + case MatchStateOption.Matched: + return translate('manage-matched-metadata.matched-status-label'); + case MatchStateOption.NotMatched: + return translate('manage-matched-metadata.unmatched-status-label'); + case MatchStateOption.Error: + return translate('manage-matched-metadata.blacklist-status-label'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts b/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts new file mode 100644 index 000000000..dcaed4f69 --- /dev/null +++ b/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts @@ -0,0 +1,45 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {MetadataSettingField} from "../admin/_models/metadata-setting-field"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'metadataSettingFiled', + standalone: true +}) +export class MetadataSettingFiledPipe implements PipeTransform { + + transform(value: MetadataSettingField): string { + switch (value) { + case MetadataSettingField.ChapterTitle: + return translate('metadata-setting-field-pipe.chapter-title'); + case MetadataSettingField.ChapterSummary: + return translate('metadata-setting-field-pipe.chapter-summary'); + case MetadataSettingField.ChapterReleaseDate: + return translate('metadata-setting-field-pipe.chapter-release-date'); + case MetadataSettingField.ChapterPublisher: + return translate('metadata-setting-field-pipe.chapter-publisher'); + case MetadataSettingField.ChapterCovers: + return translate('metadata-setting-field-pipe.chapter-covers'); + case MetadataSettingField.AgeRating: + return translate('metadata-setting-field-pipe.age-rating'); + case MetadataSettingField.People: + return translate('metadata-setting-field-pipe.people'); + case MetadataSettingField.Covers: + return translate('metadata-setting-field-pipe.covers'); + case MetadataSettingField.Summary: + return translate('metadata-setting-field-pipe.summary'); + case MetadataSettingField.PublicationStatus: + return translate('metadata-setting-field-pipe.publication-status'); + case MetadataSettingField.StartDate: + return translate('metadata-setting-field-pipe.start-date'); + case MetadataSettingField.Genres: + return translate('metadata-setting-field-pipe.genres'); + case MetadataSettingField.Tags: + return translate('metadata-setting-field-pipe.tags'); + case MetadataSettingField.LocalizedName: + return translate('metadata-setting-field-pipe.localized-name'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/person-role.pipe.ts b/UI/Web/src/app/_pipes/person-role.pipe.ts index c73f0b04d..1b9ee2163 100644 --- a/UI/Web/src/app/_pipes/person-role.pipe.ts +++ b/UI/Web/src/app/_pipes/person-role.pipe.ts @@ -1,6 +1,6 @@ -import {inject, Pipe, PipeTransform} from '@angular/core'; -import { PersonRole } from '../_models/metadata/person'; -import {TranslocoService} from "@jsverse/transloco"; +import {Pipe, PipeTransform} from '@angular/core'; +import {PersonRole} from '../_models/metadata/person'; +import {translate} from "@jsverse/transloco"; @Pipe({ name: 'personRole', @@ -8,39 +8,36 @@ import {TranslocoService} from "@jsverse/transloco"; }) export class PersonRolePipe implements PipeTransform { - translocoService = inject(TranslocoService); transform(value: PersonRole): string { switch (value) { - case PersonRole.Artist: - return this.translocoService.translate('person-role-pipe.artist'); case PersonRole.Character: - return this.translocoService.translate('person-role-pipe.character'); + return translate('person-role-pipe.character'); case PersonRole.Colorist: - return this.translocoService.translate('person-role-pipe.colorist'); + return translate('person-role-pipe.colorist'); case PersonRole.CoverArtist: - return this.translocoService.translate('person-role-pipe.artist'); + return translate('person-role-pipe.artist'); case PersonRole.Editor: - return this.translocoService.translate('person-role-pipe.editor'); + return translate('person-role-pipe.editor'); case PersonRole.Inker: - return this.translocoService.translate('person-role-pipe.inker'); + return translate('person-role-pipe.inker'); case PersonRole.Letterer: - return this.translocoService.translate('person-role-pipe.letterer'); + return translate('person-role-pipe.letterer'); case PersonRole.Penciller: - return this.translocoService.translate('person-role-pipe.penciller'); + return translate('person-role-pipe.penciller'); case PersonRole.Publisher: - return this.translocoService.translate('person-role-pipe.publisher'); + return translate('person-role-pipe.publisher'); case PersonRole.Imprint: - return this.translocoService.translate('person-role-pipe.imprint'); + return translate('person-role-pipe.imprint'); case PersonRole.Writer: - return this.translocoService.translate('person-role-pipe.writer'); + return translate('person-role-pipe.writer'); case PersonRole.Team: - return this.translocoService.translate('person-role-pipe.team'); + return translate('person-role-pipe.team'); case PersonRole.Location: - return this.translocoService.translate('person-role-pipe.location'); + return translate('person-role-pipe.location'); case PersonRole.Translator: - return this.translocoService.translate('person-role-pipe.translator'); + return translate('person-role-pipe.translator'); case PersonRole.Other: - return this.translocoService.translate('person-role-pipe.other'); + return translate('person-role-pipe.other'); default: return ''; } diff --git a/UI/Web/src/app/_pipes/plus-media-format.pipe.ts b/UI/Web/src/app/_pipes/plus-media-format.pipe.ts new file mode 100644 index 000000000..b72822e33 --- /dev/null +++ b/UI/Web/src/app/_pipes/plus-media-format.pipe.ts @@ -0,0 +1,25 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {PlusMediaFormat} from "../_models/series-detail/external-series-detail"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'plusMediaFormat', + standalone: true +}) +export class PlusMediaFormatPipe implements PipeTransform { + + transform(value: PlusMediaFormat): string { + switch (value) { + case PlusMediaFormat.Manga: + return translate('library-type-pipe.manga'); + case PlusMediaFormat.Comic: + return translate('library-type-pipe.comicVine'); + case PlusMediaFormat.LightNovel: + return translate('library-type-pipe.lightNovel'); + case PlusMediaFormat.Book: + return translate('library-type-pipe.book'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/provider-image.pipe.ts b/UI/Web/src/app/_pipes/provider-image.pipe.ts index 80574ef3b..5d845a672 100644 --- a/UI/Web/src/app/_pipes/provider-image.pipe.ts +++ b/UI/Web/src/app/_pipes/provider-image.pipe.ts @@ -17,6 +17,8 @@ export class ProviderImagePipe implements PipeTransform { return `assets/images/ExternalServices/GoogleBooks${large ? '-lg' : ''}.png`; case ScrobbleProvider.Kavita: return `assets/images/logo-${large ? '64' : '32'}.png`; + case ScrobbleProvider.Cbr: + return `assets/images/ExternalServices/ComicBookRoundup.png`; } } diff --git a/UI/Web/src/app/_pipes/provider-name.pipe.ts b/UI/Web/src/app/_pipes/provider-name.pipe.ts deleted file mode 100644 index 1947b3f5f..000000000 --- a/UI/Web/src/app/_pipes/provider-name.pipe.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import {ScrobbleProvider} from "../_services/scrobbling.service"; - -@Pipe({ - name: 'providerName', - standalone: true -}) -export class ProviderNamePipe implements PipeTransform { - - transform(value: ScrobbleProvider): string { - switch (value) { - case ScrobbleProvider.AniList: - return 'AniList'; - case ScrobbleProvider.Mal: - return 'MAL'; - case ScrobbleProvider.Kavita: - return 'Kavita'; - case ScrobbleProvider.GoogleBooks: - return 'Google Books'; - } - } - -} diff --git a/UI/Web/src/app/_pipes/read-time-left.pipe.ts b/UI/Web/src/app/_pipes/read-time-left.pipe.ts index 7ac093dd7..43ac41c86 100644 --- a/UI/Web/src/app/_pipes/read-time-left.pipe.ts +++ b/UI/Web/src/app/_pipes/read-time-left.pipe.ts @@ -1,6 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import {TranslocoService} from "@jsverse/transloco"; import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range"; +import {DecimalPipe} from "@angular/common"; @Pipe({ name: 'readTimeLeft', @@ -8,9 +9,31 @@ import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range"; }) export class ReadTimeLeftPipe implements PipeTransform { - constructor(private translocoService: TranslocoService) {} + constructor(private readonly translocoService: TranslocoService) {} transform(readingTimeLeft: HourEstimateRange): string { - return `~${readingTimeLeft.avgHours} ${readingTimeLeft.avgHours > 1 ? this.translocoService.translate('read-time-pipe.hours') : this.translocoService.translate('read-time-pipe.hour')}`; + const hoursLabel = readingTimeLeft.avgHours > 1 + ? this.translocoService.translate('read-time-pipe.hours') + : this.translocoService.translate('read-time-pipe.hour'); + + const formattedHours = this.customRound(readingTimeLeft.avgHours); + + return `~${formattedHours} ${hoursLabel}`; + } + + private customRound(value: number): string { + const integerPart = Math.floor(value); + const decimalPart = value - integerPart; + + if (decimalPart < 0.5) { + // Round down to the nearest whole number + return integerPart.toString(); + } else if (decimalPart >= 0.5 && decimalPart < 0.9) { + // Return with 1 decimal place + return value.toFixed(1); + } else { + // Round up to the nearest whole number + return Math.ceil(value).toString(); + } } } diff --git a/UI/Web/src/app/_pipes/role-localized.pipe.ts b/UI/Web/src/app/_pipes/role-localized.pipe.ts new file mode 100644 index 000000000..1890962dd --- /dev/null +++ b/UI/Web/src/app/_pipes/role-localized.pipe.ts @@ -0,0 +1,15 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {Role} from "../_services/account.service"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'roleLocalized' +}) +export class RoleLocalizedPipe implements PipeTransform { + + transform(value: Role | string): string { + const key = (value + '').toLowerCase().replace(' ', '-'); + return translate(`role-localized-pipe.${key}`); + } + +} diff --git a/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts b/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts index 7617f04ec..cc6e01449 100644 --- a/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts +++ b/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts @@ -12,6 +12,7 @@ export class ScrobbleProviderNamePipe implements PipeTransform { case ScrobbleProvider.AniList: return 'AniList'; case ScrobbleProvider.Mal: return 'MAL'; case ScrobbleProvider.Kavita: return 'Kavita'; + case ScrobbleProvider.Cbr: return 'Comicbook Roundup'; case ScrobbleProvider.GoogleBooks: return 'Google Books'; } } diff --git a/UI/Web/src/app/_pipes/sort-field.pipe.ts b/UI/Web/src/app/_pipes/sort-field.pipe.ts index 13ff4f758..d032de9c8 100644 --- a/UI/Web/src/app/_pipes/sort-field.pipe.ts +++ b/UI/Web/src/app/_pipes/sort-field.pipe.ts @@ -1,6 +1,8 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {SortField} from "../_models/metadata/series-filter"; import {TranslocoService} from "@jsverse/transloco"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Pipe({ name: 'sortField', @@ -11,7 +13,30 @@ export class SortFieldPipe implements PipeTransform { constructor(private translocoService: TranslocoService) { } - transform(value: SortField): string { + transform(value: T, entityType: ValidFilterEntity): string { + + switch (entityType) { + case 'series': + return this.seriesSortFields(value as SortField); + case 'person': + return this.personSortFields(value as PersonSortField); + + } + } + + private personSortFields(value: PersonSortField) { + switch (value) { + case PersonSortField.Name: + return this.translocoService.translate('sort-field-pipe.person-name'); + case PersonSortField.SeriesCount: + return this.translocoService.translate('sort-field-pipe.person-series-count'); + case PersonSortField.ChapterCount: + return this.translocoService.translate('sort-field-pipe.person-chapter-count'); + + } + } + + private seriesSortFields(value: SortField) { switch (value) { case SortField.SortName: return this.translocoService.translate('sort-field-pipe.sort-name'); @@ -32,7 +57,6 @@ export class SortFieldPipe implements PipeTransform { case SortField.Random: return this.translocoService.translate('sort-field-pipe.random'); } - } } diff --git a/UI/Web/src/app/_pipes/time-ago.pipe.ts b/UI/Web/src/app/_pipes/time-ago.pipe.ts index 99039126c..9940d4bb7 100644 --- a/UI/Web/src/app/_pipes/time-ago.pipe.ts +++ b/UI/Web/src/app/_pipes/time-ago.pipe.ts @@ -39,9 +39,8 @@ export class TimeAgoPipe implements PipeTransform, OnDestroy { constructor(private readonly changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, private translocoService: TranslocoService) {} - transform(value: string) { - - if (value === '' || value === null || value === undefined || value.split('T')[0] === '0001-01-01') { + transform(value: string | Date | null) { + if (value === '' || value === null || value === undefined || (typeof value === 'string' && value.split('T')[0] === '0001-01-01')) { return this.translocoService.translate('time-ago-pipe.never'); } diff --git a/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts b/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts new file mode 100644 index 000000000..0a25eefdc --- /dev/null +++ b/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts @@ -0,0 +1,24 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {DateTime} from "luxon"; + +@Pipe({ + name: 'utcToLocaleDate', + standalone: true +}) +/** + * This is the same as the UtcToLocalTimePipe but returning a timezone aware DateTime object rather than a string. + * Use this when the next operation needs a Date object (like the TimeAgoPipe) + */ +export class UtcToLocaleDatePipe implements PipeTransform { + + transform(utcDate: string | undefined | null): Date | null { + if (utcDate === '' || utcDate === null || utcDate === undefined || utcDate.split('T')[0] === '0001-01-01') { + return null; + } + + const browserLanguage = navigator.language; + const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage); + return dateTime.toJSDate() + } + +} diff --git a/UI/Web/src/app/_resolvers/reading-profile.resolver.ts b/UI/Web/src/app/_resolvers/reading-profile.resolver.ts new file mode 100644 index 000000000..1d28adf95 --- /dev/null +++ b/UI/Web/src/app/_resolvers/reading-profile.resolver.ts @@ -0,0 +1,18 @@ +import {Injectable} from '@angular/core'; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router'; +import {Observable} from 'rxjs'; +import {ReadingProfileService} from "../_services/reading-profile.service"; + +@Injectable({ + providedIn: 'root' +}) +export class ReadingProfileResolver implements Resolve { + + constructor(private readingProfileService: ReadingProfileService) {} + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + // Extract seriesId from route params or parent route + const seriesId = route.params['seriesId'] || route.parent?.params['seriesId']; + return this.readingProfileService.getForSeries(seriesId); + } +} diff --git a/UI/Web/src/app/_resolvers/url-filter.resolver.ts b/UI/Web/src/app/_resolvers/url-filter.resolver.ts new file mode 100644 index 000000000..16bc5c752 --- /dev/null +++ b/UI/Web/src/app/_resolvers/url-filter.resolver.ts @@ -0,0 +1,22 @@ +import {Injectable} from "@angular/core"; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from "@angular/router"; +import {Observable, of} from "rxjs"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; + +/** + * Checks the url for a filter and resolves one if applicable, otherwise returns null. + * It is up to the consumer to cast appropriately. + */ +@Injectable({ + providedIn: 'root' +}) +export class UrlFilterResolver implements Resolve { + + constructor(private filterUtilitiesService: FilterUtilitiesService) {} + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + if (!state.url.includes('?')) return of(null); + return this.filterUtilitiesService.decodeFilter(state.url.split('?')[1]); + } +} diff --git a/UI/Web/src/app/_routes/all-series-routing.module.ts b/UI/Web/src/app/_routes/all-series-routing.module.ts index d9dfaaf96..5c4804251 100644 --- a/UI/Web/src/app/_routes/all-series-routing.module.ts +++ b/UI/Web/src/app/_routes/all-series-routing.module.ts @@ -1,7 +1,13 @@ -import { Routes } from "@angular/router"; -import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; +import {Routes} from "@angular/router"; +import {AllSeriesComponent} from "../all-series/_components/all-series/all-series.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: AllSeriesComponent, pathMatch: 'full'}, + {path: '', component: AllSeriesComponent, pathMatch: 'full', + runGuardsAndResolvers: 'always', + resolve: { + filter: UrlFilterResolver + } + }, ]; diff --git a/UI/Web/src/app/_routes/book-reader.router.module.ts b/UI/Web/src/app/_routes/book-reader.router.module.ts index 5083c2d4a..c9d6262ad 100644 --- a/UI/Web/src/app/_routes/book-reader.router.module.ts +++ b/UI/Web/src/app/_routes/book-reader.router.module.ts @@ -1,10 +1,14 @@ -import { Routes } from '@angular/router'; -import { BookReaderComponent } from '../book-reader/_components/book-reader/book-reader.component'; +import {Routes} from '@angular/router'; +import {BookReaderComponent} from '../book-reader/_components/book-reader/book-reader.component'; +import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver"; export const routes: Routes = [ { path: ':chapterId', component: BookReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } } ]; diff --git a/UI/Web/src/app/_routes/bookmark-routing.module.ts b/UI/Web/src/app/_routes/bookmark-routing.module.ts index 6da971e08..2c7c52036 100644 --- a/UI/Web/src/app/_routes/bookmark-routing.module.ts +++ b/UI/Web/src/app/_routes/bookmark-routing.module.ts @@ -1,6 +1,12 @@ -import { Routes } from "@angular/router"; -import { BookmarksComponent } from "../bookmark/_components/bookmarks/bookmarks.component"; +import {Routes} from "@angular/router"; +import {BookmarksComponent} from "../bookmark/_components/bookmarks/bookmarks.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: BookmarksComponent, pathMatch: 'full'}, + {path: '', component: BookmarksComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, ]; diff --git a/UI/Web/src/app/_routes/browse-authors-routing.module.ts b/UI/Web/src/app/_routes/browse-authors-routing.module.ts deleted file mode 100644 index e7aab1b57..000000000 --- a/UI/Web/src/app/_routes/browse-authors-routing.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Routes } from "@angular/router"; -import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; -import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component"; - - -export const routes: Routes = [ - {path: '', component: BrowseAuthorsComponent, pathMatch: 'full'}, -]; diff --git a/UI/Web/src/app/_routes/browse-routing.module.ts b/UI/Web/src/app/_routes/browse-routing.module.ts new file mode 100644 index 000000000..be96e8193 --- /dev/null +++ b/UI/Web/src/app/_routes/browse-routing.module.ts @@ -0,0 +1,24 @@ +import {Routes} from "@angular/router"; +import {BrowsePeopleComponent} from "../browse/browse-people/browse-people.component"; +import {BrowseGenresComponent} from "../browse/browse-genres/browse-genres.component"; +import {BrowseTagsComponent} from "../browse/browse-tags/browse-tags.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; + + +export const routes: Routes = [ + // Legacy route + {path: 'authors', component: BrowsePeopleComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, + {path: 'people', component: BrowsePeopleComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, + {path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'}, + {path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'}, +]; diff --git a/UI/Web/src/app/_routes/collections-routing.module.ts b/UI/Web/src/app/_routes/collections-routing.module.ts index 80510c8f6..2b3b0ffd7 100644 --- a/UI/Web/src/app/_routes/collections-routing.module.ts +++ b/UI/Web/src/app/_routes/collections-routing.module.ts @@ -1,9 +1,15 @@ -import { Routes } from '@angular/router'; -import { AllCollectionsComponent } from '../collections/_components/all-collections/all-collections.component'; -import { CollectionDetailComponent } from '../collections/_components/collection-detail/collection-detail.component'; +import {Routes} from '@angular/router'; +import {AllCollectionsComponent} from '../collections/_components/all-collections/all-collections.component'; +import {CollectionDetailComponent} from '../collections/_components/collection-detail/collection-detail.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ {path: '', component: AllCollectionsComponent, pathMatch: 'full'}, - {path: ':id', component: CollectionDetailComponent}, + {path: ':id', component: CollectionDetailComponent, + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, ]; diff --git a/UI/Web/src/app/_routes/library-detail-routing.module.ts b/UI/Web/src/app/_routes/library-detail-routing.module.ts index 04cb3c9dd..3c09a71ee 100644 --- a/UI/Web/src/app/_routes/library-detail-routing.module.ts +++ b/UI/Web/src/app/_routes/library-detail-routing.module.ts @@ -1,7 +1,8 @@ -import { Routes } from '@angular/router'; -import { AuthGuard } from '../_guards/auth.guard'; -import { LibraryAccessGuard } from '../_guards/library-access.guard'; -import { LibraryDetailComponent } from '../library-detail/library-detail.component'; +import {Routes} from '@angular/router'; +import {AuthGuard} from '../_guards/auth.guard'; +import {LibraryAccessGuard} from '../_guards/library-access.guard'; +import {LibraryDetailComponent} from '../library-detail/library-detail.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ @@ -9,12 +10,18 @@ export const routes: Routes = [ path: ':libraryId', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent + component: LibraryDetailComponent, + resolve: { + filter: UrlFilterResolver + }, }, { path: '', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent - } + component: LibraryDetailComponent, + resolve: { + filter: UrlFilterResolver + }, + }, ]; diff --git a/UI/Web/src/app/_routes/manga-reader.router.module.ts b/UI/Web/src/app/_routes/manga-reader.router.module.ts index 04ff77b3c..e479e8ae6 100644 --- a/UI/Web/src/app/_routes/manga-reader.router.module.ts +++ b/UI/Web/src/app/_routes/manga-reader.router.module.ts @@ -1,15 +1,22 @@ -import { Routes } from '@angular/router'; -import { MangaReaderComponent } from '../manga-reader/_components/manga-reader/manga-reader.component'; +import {Routes} from '@angular/router'; +import {MangaReaderComponent} from '../manga-reader/_components/manga-reader/manga-reader.component'; +import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver"; export const routes: Routes = [ { path: ':chapterId', - component: MangaReaderComponent + component: MangaReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } }, { // This will allow the MangaReader to have a list to use for next/prev chapters rather than natural sort order path: ':chapterId/list/:listId', - component: MangaReaderComponent + component: MangaReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } } ]; diff --git a/UI/Web/src/app/_routes/pdf-reader.router.module.ts b/UI/Web/src/app/_routes/pdf-reader.router.module.ts index a55699280..7cb9f68e2 100644 --- a/UI/Web/src/app/_routes/pdf-reader.router.module.ts +++ b/UI/Web/src/app/_routes/pdf-reader.router.module.ts @@ -1,9 +1,13 @@ -import { Routes } from '@angular/router'; -import { PdfReaderComponent } from '../pdf-reader/_components/pdf-reader/pdf-reader.component'; +import {Routes} from '@angular/router'; +import {PdfReaderComponent} from '../pdf-reader/_components/pdf-reader/pdf-reader.component'; +import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver"; export const routes: Routes = [ { path: ':chapterId', component: PdfReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } } ]; diff --git a/UI/Web/src/app/_routes/want-to-read-routing.module.ts b/UI/Web/src/app/_routes/want-to-read-routing.module.ts index b3301d9f9..b593172c0 100644 --- a/UI/Web/src/app/_routes/want-to-read-routing.module.ts +++ b/UI/Web/src/app/_routes/want-to-read-routing.module.ts @@ -1,6 +1,10 @@ -import { Routes } from '@angular/router'; -import { WantToReadComponent } from '../want-to-read/_components/want-to-read/want-to-read.component'; +import {Routes} from '@angular/router'; +import {WantToReadComponent} from '../want-to-read/_components/want-to-read/want-to-read.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: WantToReadComponent, pathMatch: 'full'}, + {path: '', component: WantToReadComponent, pathMatch: 'full', runGuardsAndResolvers: 'always', resolve: { + filter: UrlFilterResolver + } + }, ]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 54b54931f..f1f91143f 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -1,20 +1,22 @@ -import { HttpClient } from '@angular/common/http'; -import {DestroyRef, inject, Injectable } from '@angular/core'; -import {catchError, Observable, of, ReplaySubject, shareReplay, throwError} from 'rxjs'; +import {HttpClient} from '@angular/common/http'; +import {DestroyRef, inject, Injectable} from '@angular/core'; +import {Observable, of, ReplaySubject, shareReplay} from 'rxjs'; import {filter, map, switchMap, tap} from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { Preferences } from '../_models/preferences/preferences'; -import { User } from '../_models/user'; -import { Router } from '@angular/router'; -import { EVENTS, MessageHubService } from './message-hub.service'; -import { ThemeService } from './theme.service'; -import { InviteUserResponse } from '../_models/auth/invite-user-response'; -import { UserUpdateEvent } from '../_models/events/user-update-event'; -import { AgeRating } from '../_models/metadata/age-rating'; -import { AgeRestriction } from '../_models/metadata/age-restriction'; -import { TextResonse } from '../_types/text-response'; +import {environment} from 'src/environments/environment'; +import {Preferences} from '../_models/preferences/preferences'; +import {User} from '../_models/user'; +import {Router} from '@angular/router'; +import {EVENTS, MessageHubService} from './message-hub.service'; +import {ThemeService} from './theme.service'; +import {InviteUserResponse} from '../_models/auth/invite-user-response'; +import {UserUpdateEvent} from '../_models/events/user-update-event'; +import {AgeRating} from '../_models/metadata/age-rating'; +import {AgeRestriction} from '../_models/metadata/age-restriction'; +import {TextResonse} from '../_types/text-response'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {Action} from "./action-factory.service"; +import {LicenseService} from "./license.service"; +import {LocalizationService} from "./localization.service"; export enum Role { Admin = 'Admin', @@ -27,12 +29,25 @@ export enum Role { Promote = 'Promote', } +export const allRoles = [ + Role.Admin, + Role.ChangePassword, + Role.Bookmark, + Role.Download, + Role.ChangeRestriction, + Role.ReadOnly, + Role.Login, + Role.Promote, +] + @Injectable({ providedIn: 'root' }) export class AccountService { private readonly destroyRef = inject(DestroyRef); + private readonly licenseService = inject(LicenseService); + private readonly localizationService = inject(LocalizationService); baseUrl = environment.apiUrl; userKey = 'kavita-user'; @@ -42,17 +57,13 @@ export class AccountService { // Stores values, when someone subscribes gives (1) of last values seen. private currentUserSource = new ReplaySubject(1); - public currentUser$ = this.currentUserSource.asObservable(); + public currentUser$ = this.currentUserSource.asObservable().pipe(takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true})); public isAdmin$: Observable = this.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => { if (!u) return false; return this.hasAdminRole(u); }), shareReplay({bufferSize: 1, refCount: true})); - private hasValidLicenseSource = new ReplaySubject(1); - /** - * Does the user have an active license - */ - public hasValidLicense$ = this.hasValidLicenseSource.asObservable(); + /** * SetTimeout handler for keeping track of refresh token call @@ -91,17 +102,63 @@ export class AccountService { return true; } - hasAnyRole(user: User, roles: Array) { + /** + * If the user has any role in the restricted roles array or is an Admin + * @param user + * @param roles + * @param restrictedRoles + */ + hasAnyRole(user: User, roles: Array, restrictedRoles: Array = []) { if (!user || !user.roles) { return false; } + + // If the user is an admin, they have the role + if (this.hasAdminRole(user)) { + return true; + } + + // If restricted roles are provided and the user has any of them, deny access + if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { + return false; + } + + // If roles are empty, allow access (no restrictions by roles) if (roles.length === 0) { return true; } + // Allow access if the user has any of the allowed roles return roles.some(role => user.roles.includes(role)); } + /** + * If User or Admin, will return false + * @param user + * @param restrictedRoles + */ + hasAnyRestrictedRole(user: User, restrictedRoles: Array = []) { + if (!user || !user.roles) { + return true; + } + + if (restrictedRoles.length === 0) { + return false; + } + + // If the user is an admin, they have the role + if (this.hasAdminRole(user)) { + return false; + } + + + if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { + return true; + } + + return false; + } + hasAdminRole(user: User) { return user && user.roles.includes(Role.Admin); } @@ -111,7 +168,7 @@ export class AccountService { } hasChangeAgeRestrictionRole(user: User) { - return user && user.roles.includes(Role.ChangeRestriction); + return user && !user.roles.includes(Role.Admin) && user.roles.includes(Role.ChangeRestriction); } hasDownloadRole(user: User) { @@ -134,40 +191,7 @@ export class AccountService { return this.httpClient.get(this.baseUrl + 'account/roles'); } - deleteLicense() { - return this.httpClient.delete(this.baseUrl + 'license', TextResonse); - } - resetLicense(license: string, email: string) { - return this.httpClient.post(this.baseUrl + 'license/reset', {license, email}, TextResonse); - } - - hasValidLicense(forceCheck: boolean = false) { - console.log('hasValidLicense being called: ', forceCheck); - return this.httpClient.get(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse) - .pipe( - map(res => res === "true"), - tap(res => { - this.hasValidLicenseSource.next(res) - }), - catchError(error => { - this.hasValidLicenseSource.next(false); - return throwError(error); // Rethrow the error to propagate it further - }) - ); - } - - hasAnyLicense() { - return this.httpClient.get(this.baseUrl + 'license/has-license', TextResonse) - .pipe( - map(res => res === "true"), - ); - } - - updateUserLicense(license: string, email: string, discordId?: string) { - return this.httpClient.post(this.baseUrl + 'license', {license, email, discordId}, TextResonse) - .pipe(map(res => res === "true")); - } login(model: {username: string, password: string, apiKey?: string}) { return this.httpClient.post(this.baseUrl + 'account/login', model).pipe( @@ -182,6 +206,8 @@ export class AccountService { } setCurrentUser(user?: User, refreshConnections = true) { + + const isSameUser = this.currentUser === user; if (user) { user.roles = []; const roles = this.getDecodedToken(user.token).role; @@ -209,9 +235,11 @@ export class AccountService { if (this.currentUser) { // BUG: StopHubConnection has a promise in it, this needs to be async // But that really messes everything up - this.messageHub.stopHubConnection(); - this.messageHub.createHubConnection(this.currentUser); - this.hasValidLicense().subscribe(); + if (!isSameUser) { + this.messageHub.stopHubConnection(); + this.messageHub.createHubConnection(this.currentUser); + this.licenseService.hasValidLicense().subscribe(); + } this.startRefreshTokenTimer(); } } @@ -330,6 +358,8 @@ export class AccountService { // Update the locale on disk (for logout and compact-number pipe) localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale); + this.localizationService.refreshTranslations(this.currentUser.preferences.locale); + } return settings; }), takeUntilDestroyed(this.destroyRef)); diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 24ca2a76a..e5967bf24 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -1,18 +1,19 @@ -import { Injectable } from '@angular/core'; -import { map, Observable, shareReplay } from 'rxjs'; -import { Chapter } from '../_models/chapter'; +import {Injectable} from '@angular/core'; +import {map, Observable, shareReplay} from 'rxjs'; +import {Chapter} from '../_models/chapter'; import {UserCollection} from '../_models/collection-tag'; -import { Device } from '../_models/device/device'; -import { Library } from '../_models/library/library'; -import { ReadingList } from '../_models/reading-list'; -import { Series } from '../_models/series'; -import { Volume } from '../_models/volume'; -import { AccountService } from './account.service'; -import { DeviceService } from './device.service'; +import {Device} from '../_models/device/device'; +import {Library} from '../_models/library/library'; +import {ReadingList} from '../_models/reading-list'; +import {Series} from '../_models/series'; +import {Volume} from '../_models/volume'; +import {AccountService, Role} from './account.service'; +import {DeviceService} from './device.service'; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {translate} from "@jsverse/transloco"; import {Person} from "../_models/metadata/person"; +import {User} from '../_models/user'; export enum Action { Submenu = -1, @@ -106,26 +107,49 @@ export enum Action { Promote = 24, UnPromote = 25, /** - * Invoke a refresh covers as false to generate colorscapes + * Invoke refresh covers as false to generate colorscapes */ GenerateColorScape = 26, /** * Copy settings from one entity to another */ - CopySettings = 27 + CopySettings = 27, + /** + * Match an entity with an upstream system + */ + Match = 28, + /** + * Merge two (or more?) entities + */ + Merge = 29, + /** + * Add to a reading profile + */ + SetReadingProfile = 30, + /** + * Remove the reading profile from the entity + */ + ClearReadingProfile = 31, } /** * Callback for an action */ -export type ActionCallback = (action: ActionItem, data: T) => void; -export type ActionAllowedCallback = (action: ActionItem) => boolean; +export type ActionCallback = (action: ActionItem, entity: T) => void; +export type ActionShouldRenderFunc = (action: ActionItem, entity: T, user: User) => boolean; export interface ActionItem { title: string; description: string; action: Action; callback: ActionCallback; + /** + * Roles required to be present for ActionItem to show. If empty, assumes anyone can see. At least one needs to apply. + */ + requiredRoles: Role[]; + /** + * @deprecated Use required Roles instead + */ requiresAdmin: boolean; children: Array>; /** @@ -141,88 +165,98 @@ export interface ActionItem { * Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return */ _extra?: {title: string, data: any}; + /** + * Will call on each action to determine if it should show for the appropriate entity based on state and user + */ + shouldRender: ActionShouldRenderFunc; } +/** + * Entities that can be actioned upon + */ +export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCollection | Person | Library | SideNavStream | SmartFilter | null; + @Injectable({ providedIn: 'root', }) export class ActionFactoryService { - libraryActions: Array> = []; - - seriesActions: Array> = []; - - volumeActions: Array> = []; - - chapterActions: Array> = []; - - collectionTagActions: Array> = []; - - readingListActions: Array> = []; - - bookmarkActions: Array> = []; - + private libraryActions: Array> = []; + private seriesActions: Array> = []; + private volumeActions: Array> = []; + private chapterActions: Array> = []; + private collectionTagActions: Array> = []; + private readingListActions: Array> = []; + private bookmarkActions: Array> = []; private personActions: Array> = []; - - sideNavStreamActions: Array> = []; - smartFilterActions: Array> = []; - - isAdmin = false; - + private sideNavStreamActions: Array> = []; + private smartFilterActions: Array> = []; + private sideNavHomeActions: Array> = []; constructor(private accountService: AccountService, private deviceService: DeviceService) { - this.accountService.currentUser$.subscribe((user) => { - if (user) { - this.isAdmin = this.accountService.hasAdminRole(user); - } else { - this._resetActions(); - return; // If user is logged out, we don't need to do anything - } - + this.accountService.currentUser$.subscribe((_) => { this._resetActions(); }); } - getLibraryActions(callback: ActionCallback) { - return this.applyCallbackToList(this.libraryActions, callback); + getLibraryActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem[]; } - getSeriesActions(callback: ActionCallback) { - return this.applyCallbackToList(this.seriesActions, callback); + getSeriesActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc); } - getSideNavStreamActions(callback: ActionCallback) { - return this.applyCallbackToList(this.sideNavStreamActions, callback); + getSideNavStreamActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc); } - getSmartFilterActions(callback: ActionCallback) { - return this.applyCallbackToList(this.smartFilterActions, callback); + getSmartFilterActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc); } - getVolumeActions(callback: ActionCallback) { - return this.applyCallbackToList(this.volumeActions, callback); + getVolumeActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc); } - getChapterActions(callback: ActionCallback) { - return this.applyCallbackToList(this.chapterActions, callback); + getChapterActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc); } - getCollectionTagActions(callback: ActionCallback) { - return this.applyCallbackToList(this.collectionTagActions, callback); + getCollectionTagActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc); } - getReadingListActions(callback: ActionCallback) { - return this.applyCallbackToList(this.readingListActions, callback); + getReadingListActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc); } - getBookmarkActions(callback: ActionCallback) { - return this.applyCallbackToList(this.bookmarkActions, callback); + getBookmarkActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc); } - getPersonActions(callback: ActionCallback) { - return this.applyCallbackToList(this.personActions, callback); + getPersonActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc); } - dummyCallback(action: ActionItem, data: any) {} + getSideNavHomeActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc); + } + + dummyCallback(action: ActionItem, entity: any) {} + dummyShouldRender(action: ActionItem, entity: any, user: User) {return true;} + basicReadRender(action: ActionItem, entity: any, user: User) { + if (entity === null || entity === undefined) return true; + if (!entity.hasOwnProperty('pagesRead') && !entity.hasOwnProperty('pages')) return true; + + switch (action.action) { + case(Action.MarkAsRead): + return entity.pagesRead < entity.pages; + case(Action.MarkAsUnread): + return entity.pagesRead !== 0; + default: + return true; + } + } filterSendToAction(actions: Array>, chapter: Chapter) { // if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) { @@ -265,7 +299,7 @@ export class ActionFactoryService { return tasks.filter(t => !blacklist.includes(t.action)); } - getBulkLibraryActions(callback: ActionCallback) { + getBulkLibraryActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { // Scan is currently not supported due to the backend not being able to handle it yet const actions = this.flattenActions(this.libraryActions).filter(a => { @@ -279,11 +313,13 @@ export class ActionFactoryService { dynamicList: undefined, action: Action.CopySettings, callback: this.dummyCallback, + shouldRender: shouldRenderFunc, children: [], + requiredRoles: [Role.Admin], requiresAdmin: true, title: 'copy-settings' }) - return this.applyCallbackToList(actions, callback); + return this.applyCallbackToList(actions, callback, shouldRenderFunc) as ActionItem[]; } flattenActions(actions: Array>): Array> { @@ -309,22 +345,59 @@ export class ActionFactoryService { title: 'scan-library', description: 'scan-library-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, + { + action: Action.Submenu, + title: 'reading-profiles', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [ + { + action: Action.SetReadingProfile, + title: 'set-reading-profile', + description: 'set-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + { + action: Action.ClearReadingProfile, + title: 'clear-reading-profile', + description: 'clear-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + ], + }, { action: Action.Submenu, title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', description: 'refresh-covers-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -332,7 +405,9 @@ export class ActionFactoryService { title: 'generate-colorscape', description: 'generate-colorscape-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -340,7 +415,9 @@ export class ActionFactoryService { title: 'analyze-files', description: 'analyze-files-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -348,7 +425,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ], @@ -358,7 +437,9 @@ export class ActionFactoryService { title: 'settings', description: 'settings-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ]; @@ -369,7 +450,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -377,7 +460,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], class: 'danger', children: [], }, @@ -386,7 +471,9 @@ export class ActionFactoryService { title: 'promote', description: 'promote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -394,7 +481,9 @@ export class ActionFactoryService { title: 'unpromote', description: 'unpromote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -405,7 +494,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -413,7 +504,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -421,7 +514,9 @@ export class ActionFactoryService { title: 'scan-series', description: 'scan-series-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -429,14 +524,18 @@ export class ActionFactoryService { title: 'add-to', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToWantToReadList, title: 'add-to-want-to-read', description: 'add-to-want-to-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -444,7 +543,9 @@ export class ActionFactoryService { title: 'remove-from-want-to-read', description: 'remove-to-want-to-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -452,7 +553,9 @@ export class ActionFactoryService { title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -460,25 +563,11 @@ export class ActionFactoryService { title: 'add-to-collection', description: 'add-to-collection-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], - }, - // { - // action: Action.AddToScrobbleHold, - // title: 'add-to-scrobble-hold', - // description: 'add-to-scrobble-hold-tooltip', - // callback: this.dummyCallback, - // requiresAdmin: true, - // children: [], - // }, - // { - // action: Action.RemoveFromScrobbleHold, - // title: 'remove-from-scrobble-hold', - // description: 'remove-from-scrobble-hold-tooltip', - // callback: this.dummyCallback, - // requiresAdmin: true, - // children: [], - // }, + } ], }, { @@ -486,14 +575,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -501,19 +594,54 @@ export class ActionFactoryService { } ], }, + { + action: Action.Submenu, + title: 'reading-profiles', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [ + { + action: Action.SetReadingProfile, + title: 'set-reading-profile', + description: 'set-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + { + action: Action.ClearReadingProfile, + title: 'clear-reading-profile', + description: 'clear-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + ], + }, { action: Action.Submenu, title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [], children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', description: 'refresh-covers-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -521,7 +649,9 @@ export class ActionFactoryService { title: 'generate-colorscape', description: 'generate-colorscape-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -529,7 +659,9 @@ export class ActionFactoryService { title: 'analyze-files', description: 'analyze-files-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -537,18 +669,32 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], class: 'danger', children: [], }, ], }, + { + action: Action.Match, + title: 'match', + description: 'match-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: true, + requiredRoles: [Role.Admin], + children: [], + }, { action: Action.Download, title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [Role.Download], children: [], }, { @@ -556,7 +702,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ]; @@ -567,7 +715,9 @@ export class ActionFactoryService { title: 'read-incognito', description: 'read-incognito-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -575,7 +725,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -583,7 +735,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -591,14 +745,18 @@ export class ActionFactoryService { title: 'add-to', description: '=', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], } ] @@ -608,14 +766,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -628,14 +790,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.Delete, title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -643,7 +809,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ] @@ -653,7 +821,9 @@ export class ActionFactoryService { title: 'details', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -664,7 +834,9 @@ export class ActionFactoryService { title: 'read-incognito', description: 'read-incognito-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -672,7 +844,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -680,7 +854,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -688,14 +864,18 @@ export class ActionFactoryService { title: 'add-to', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], } ] @@ -705,14 +885,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -726,14 +910,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.Delete, title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -741,7 +929,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [Role.Download], children: [], }, ] @@ -751,7 +941,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -762,7 +954,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -770,7 +964,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], class: 'danger', children: [], }, @@ -779,7 +975,9 @@ export class ActionFactoryService { title: 'promote', description: 'promote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -787,7 +985,9 @@ export class ActionFactoryService { title: 'unpromote', description: 'unpromote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -798,7 +998,19 @@ export class ActionFactoryService { title: 'edit', description: 'edit-person-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], + children: [], + }, + { + action: Action.Merge, + title: 'merge', + description: 'merge-person-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], } ]; @@ -809,7 +1021,9 @@ export class ActionFactoryService { title: 'view-series', description: 'view-series-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -817,7 +1031,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -825,8 +1041,10 @@ export class ActionFactoryService { title: 'clear', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, class: 'danger', requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -837,7 +1055,9 @@ export class ActionFactoryService { title: 'mark-visible', description: 'mark-visible-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -845,38 +1065,76 @@ export class ActionFactoryService { title: 'mark-invisible', description: 'mark-invisible-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; this.smartFilterActions = [ + { + action: Action.Edit, + title: 'rename', + description: 'rename-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, { action: Action.Delete, title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; + + this.sideNavHomeActions = [ + { + action: Action.Edit, + title: 'reorder', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + } + ] + + } - private applyCallback(action: ActionItem, callback: (action: ActionItem, data: any) => void) { + private applyCallback(action: ActionItem, callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc) { action.callback = callback; + action.shouldRender = shouldRenderFunc; if (action.children === null || action.children?.length === 0) return; - action.children?.forEach((childAction) => { - this.applyCallback(childAction, callback); + // Ensure action children are a copy of the parent (since parent does a shallow mapping) + action.children = action.children.map(d => { return {...d}; }); + + action.children.forEach((childAction) => { + this.applyCallback(childAction, callback, shouldRenderFunc); }); } - public applyCallbackToList(list: Array>, callback: (action: ActionItem, data: any) => void): Array> { + public applyCallbackToList(list: Array>, + callback: ActionCallback, + shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender): Array> { + // Create a clone of the list to ensure we aren't affecting the default state const actions = list.map((a) => { return { ...a }; }); - actions.forEach((action) => this.applyCallback(action, callback)); + + actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc)); + return actions; } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 5ccdf9b22..2328bf72e 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -1,32 +1,40 @@ -import {inject, Injectable, OnDestroy} from '@angular/core'; -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; -import { ToastrService } from 'ngx-toastr'; -import {Subject, tap} from 'rxjs'; -import { take } from 'rxjs/operators'; -import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; -import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; -import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; -import { ConfirmService } from '../shared/confirm.service'; -import { LibrarySettingsModalComponent } from '../sidenav/_modals/library-settings-modal/library-settings-modal.component'; -import { Chapter } from '../_models/chapter'; -import { Device } from '../_models/device/device'; -import { Library } from '../_models/library/library'; -import { ReadingList } from '../_models/reading-list'; -import { Series } from '../_models/series'; -import { Volume } from '../_models/volume'; -import { DeviceService } from './device.service'; -import { LibraryService } from './library.service'; -import { MemberService } from './member.service'; -import { ReaderService } from './reader.service'; -import { SeriesService } from './series.service'; +import {inject, Injectable} from '@angular/core'; +import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import {ToastrService} from 'ngx-toastr'; +import {take} from 'rxjs/operators'; +import {BulkAddToCollectionComponent} from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; +import {ADD_FLOW, AddToListModalComponent} from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; +import { + EditReadingListModalComponent +} from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; +import {ConfirmService} from '../shared/confirm.service'; +import { + LibrarySettingsModalComponent +} from '../sidenav/_modals/library-settings-modal/library-settings-modal.component'; +import {Chapter} from '../_models/chapter'; +import {Device} from '../_models/device/device'; +import {Library} from '../_models/library/library'; +import {ReadingList} from '../_models/reading-list'; +import {Series} from '../_models/series'; +import {Volume} from '../_models/volume'; +import {DeviceService} from './device.service'; +import {LibraryService} from './library.service'; +import {MemberService} from './member.service'; +import {ReaderService} from './reader.service'; +import {SeriesService} from './series.service'; import {translate} from "@jsverse/transloco"; import {UserCollection} from "../_models/collection-tag"; import {CollectionTagService} from "./collection-tag.service"; -import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {FilterService} from "./filter.service"; import {ReadingListService} from "./reading-list.service"; import {ChapterService} from "./chapter.service"; import {VolumeService} from "./volume.service"; +import {DefaultModalOptions} from "../_models/default-modal-options"; +import {MatchSeriesModalComponent} from "../_single-module/match-series-modal/match-series-modal.component"; +import { + BulkSetReadingProfileModalComponent +} from "../cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component"; + export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; @@ -121,7 +129,7 @@ export class ActionService { } editLibrary(library: Partial, callback?: LibraryActionCallback) { - const modalRef = this.modalService.open(LibrarySettingsModalComponent, {size: 'xl', fullscreen: 'md'}); + const modalRef = this.modalService.open(LibrarySettingsModalComponent, DefaultModalOptions); modalRef.componentInstance.library = library; modalRef.closed.subscribe((closeResult: {success: boolean, library: Library, coverImageUpdate: boolean}) => { if (callback) callback(library) @@ -467,6 +475,26 @@ export class ActionService { }); } + async deleteMultipleVolumes(volumes: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return; + + this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => { + if (callback) { + callback(success); + } + }) + } + + async deleteMultipleChapters(seriesId: number, chapterIds: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: chapterIds.length}))) return; + + this.chapterService.deleteMultipleChapters(seriesId, chapterIds.map(c => c.id)).subscribe(() => { + if (callback) { + callback(true); + } + }); + } + /** * Deletes multiple collections * @param readingLists ReadingList, should have id @@ -490,7 +518,7 @@ export class ActionService { this.readingListModalRef.componentInstance.seriesId = seriesId; this.readingListModalRef.componentInstance.volumeIds = volumes.map(v => v.id); this.readingListModalRef.componentInstance.chapterIds = chapters?.map(c => c.id); - this.readingListModalRef.componentInstance.title = translate('action.multiple-selections'); + this.readingListModalRef.componentInstance.title = translate('actionable.multiple-selections'); this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple; @@ -510,7 +538,7 @@ export class ActionService { addMultipleSeriesToWantToReadList(seriesIds: Array, callback?: VoidActionCallback) { this.memberService.addSeriesToWantToRead(seriesIds).subscribe(() => { - this.toastr.success('Series added to Want to Read list'); + this.toastr.success(translate('toasts.series-added-want-to-read')); if (callback) { callback(); } @@ -530,7 +558,7 @@ export class ActionService { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id); - this.readingListModalRef.componentInstance.title = translate('action.multiple-selections'); + this.readingListModalRef.componentInstance.title = translate('actionable.multiple-selections'); this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple_Series; @@ -641,7 +669,7 @@ export class ActionService { } editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) { - const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'lg', fullscreen: 'md' }); + const readingListModalRef = this.modalService.open(EditReadingListModalComponent, DefaultModalOptions); readingListModalRef.componentInstance.readingList = readingList; readingListModalRef.closed.pipe(take(1)).subscribe((list) => { if (callback && list !== undefined) { @@ -761,6 +789,16 @@ export class ActionService { }); } + matchSeries(series: Series, callback?: BooleanActionCallback) { + const ref = this.modalService.open(MatchSeriesModalComponent, DefaultModalOptions); + ref.componentInstance.series = series; + ref.closed.subscribe(saved => { + if (callback) { + callback(saved); + } + }); + } + async deleteFilter(filterId: number, callback?: BooleanActionCallback) { if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) { if (callback) { @@ -778,4 +816,56 @@ export class ActionService { }); } + /** + * Sets the reading profile for multiple series + * @param series + * @param callback + */ + setReadingProfileForMultiple(series: Array, callback?: BooleanActionCallback) { + if (this.readingListModalRef != null) { return; } + + this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); + this.readingListModalRef.componentInstance.seriesIds = series.map(s => s.id) + this.readingListModalRef.componentInstance.title = "" + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(true); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(false); + } + }); + } + + /** + * Sets the reading profile for multiple series + * @param library + * @param callback + */ + setReadingProfileForLibrary(library: Library, callback?: BooleanActionCallback) { + if (this.readingListModalRef != null) { return; } + + this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); + this.readingListModalRef.componentInstance.libraryId = library.id; + this.readingListModalRef.componentInstance.title = "" + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(true); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(false); + } + }); + } + } diff --git a/UI/Web/src/app/_services/chapter.service.ts b/UI/Web/src/app/_services/chapter.service.ts index e373b9096..6a6f7a600 100644 --- a/UI/Web/src/app/_services/chapter.service.ts +++ b/UI/Web/src/app/_services/chapter.service.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@angular/core'; +import {Injectable} from '@angular/core'; import {environment} from "../../environments/environment"; import {HttpClient} from "@angular/common/http"; import {Chapter} from "../_models/chapter"; import {TextResonse} from "../_types/text-response"; +import {ChapterDetailPlus} from "../_models/chapter-detail-plus"; @Injectable({ providedIn: 'root' @@ -21,8 +22,16 @@ export class ChapterService { return this.httpClient.delete(this.baseUrl + 'chapter?chapterId=' + chapterId); } + deleteMultipleChapters(seriesId: number, chapterIds: Array) { + return this.httpClient.post(this.baseUrl + `chapter/delete-multiple?seriesId=${seriesId}`, {chapterIds}); + } + updateChapter(chapter: Chapter) { return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse); } + chapterDetailPlus(seriesId: number, chapterId: number) { + return this.httpClient.get(this.baseUrl + `chapter/chapter-detail-plus?chapterId=${chapterId}&seriesId=${seriesId}`); + } + } diff --git a/UI/Web/src/app/_services/collection-tag.service.ts b/UI/Web/src/app/_services/collection-tag.service.ts index a256c43e9..df668f13a 100644 --- a/UI/Web/src/app/_services/collection-tag.service.ts +++ b/UI/Web/src/app/_services/collection-tag.service.ts @@ -1,4 +1,4 @@ -import {HttpClient} from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import {Injectable} from '@angular/core'; import {environment} from 'src/environments/environment'; import {UserCollection} from '../_models/collection-tag'; diff --git a/UI/Web/src/app/_services/colorscape.service.ts b/UI/Web/src/app/_services/colorscape.service.ts index 8632c8218..88cbd7460 100644 --- a/UI/Web/src/app/_services/colorscape.service.ts +++ b/UI/Web/src/app/_services/colorscape.service.ts @@ -1,6 +1,7 @@ import { Injectable, Inject } from '@angular/core'; import { DOCUMENT } from '@angular/common'; -import { BehaviorSubject } from 'rxjs'; +import {BehaviorSubject, filter, take, tap, timer} from 'rxjs'; +import {NavigationEnd, Router} from "@angular/router"; interface ColorSpace { primary: string; @@ -16,18 +17,9 @@ interface ColorSpaceRGBA { complementary: RGBAColor; } -interface RGBAColor { - r: number; - g: number; - b: number; - a: number; -} - -interface RGB { - r: number; - g: number; - b: number; -} +interface RGBAColor {r: number;g: number;b: number;a: number;} +interface RGB { r: number;g: number; b: number; } +interface HSL { h: number; s: number; l: number; } const colorScapeSelector = 'colorscape'; @@ -39,15 +31,42 @@ const colorScapeSelector = 'colorscape'; }) export class ColorscapeService { private colorSubject = new BehaviorSubject(null); + private colorSeedSubject = new BehaviorSubject<{primary: string, complementary: string | null} | null>(null); public readonly colors$ = this.colorSubject.asObservable(); private minDuration = 1000; // minimum duration private maxDuration = 4000; // maximum duration + private defaultColorspaceDuration = 300; // duration to wait before defaulting back to default colorspace - constructor(@Inject(DOCUMENT) private document: Document) { + constructor(@Inject(DOCUMENT) private document: Document, private readonly router: Router) { + this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + tap(() => this.checkAndResetColorscapeAfterDelay()) + ).subscribe(); } + /** + * Due to changing ColorScape on route end, we might go from one space to another, but the router events resets to default + * This delays it to see if the colors changed or not in 500ms and if not, then we will reset to default. + * @private + */ + private checkAndResetColorscapeAfterDelay() { + // Capture the current colors at the start of NavigationEnd + const initialColors = this.colorSubject.getValue(); + + // Wait for X ms, then check if colors have changed + timer(this.defaultColorspaceDuration).pipe( + take(1), // Complete after the timer emits + tap(() => { + const currentColors = this.colorSubject.getValue(); + if (initialColors != null && currentColors != null && this.areColorSpacesVisuallyEqual(initialColors, currentColors)) { + this.setColorScape(''); // Reset to default if colors haven't changed + } + }) + ).subscribe(); + } + /** * Sets a color scape for the active theme * @param primaryColor @@ -64,6 +83,23 @@ export class ColorscapeService { return; } + // Check the old seed colors and check if they are similar, then avoid a change. In case you scan a series and this re-generates + const previousColors = this.colorSeedSubject.getValue(); + if (previousColors != null && primaryColor == previousColors.primary) { + this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor}); + return; + } + this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor}); + + // TODO: Check if there is a secondary color and if the color is a strong contrast (opposite on color wheel) to primary + + // If we have a STRONG primary and secondary, generate LEFT/RIGHT orientation + // If we have only one color, randomize the position of the primary + // If we have 2 colors, but their contrast isn't STRONG, then use diagonal for Primary and Secondary + + + + const newColors: ColorSpace = primaryColor ? this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) : this.defaultColors(); @@ -72,7 +108,6 @@ export class ColorscapeService { const oldColors = this.colorSubject.getValue() || this.convertColorsToRGBA(this.defaultColors()); const duration = this.calculateTransitionDuration(oldColors, newColorsRGBA); - // Check if the colors we are transitioning to are visually equal if (this.areColorSpacesVisuallyEqual(oldColors, newColorsRGBA)) { return; @@ -106,7 +141,8 @@ export class ColorscapeService { }; } - private parseColorToRGBA(color: string): RGBAColor { + private parseColorToRGBA(color: string) { + if (color.startsWith('#')) { return this.hexToRGBA(color); } else if (color.startsWith('rgb')) { @@ -156,7 +192,10 @@ export class ColorscapeService { const normalizedDistance = Math.min(totalDistance / (255 * 3 * 4), 1); // Max possible distance is 255*3*4 const duration = this.minDuration + normalizedDistance * (this.maxDuration - this.minDuration); - return Math.round(duration); + // Add random variance to the duration + const durationVariance = this.getRandomInRange(-500, 500); + + return Math.round(duration + durationVariance); } private rgbaToRgb(rgba: RGBAColor): RGB { @@ -205,12 +244,19 @@ export class ColorscapeService { requestAnimationFrame(animate); } + private easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + } + private interpolateRGBAColor(color1: RGBAColor, color2: RGBAColor, progress: number): RGBAColor { + + const easedProgress = this.easeInOutCubic(progress); + return { - r: Math.round(color1.r + (color2.r - color1.r) * progress), - g: Math.round(color1.g + (color2.g - color1.g) * progress), - b: Math.round(color1.b + (color2.b - color1.b) * progress), - a: color1.a + (color2.a - color1.a) * progress + r: Math.round(color1.r + (color2.r - color1.r) * easedProgress), + g: Math.round(color1.g + (color2.g - color1.g) * easedProgress), + b: Math.round(color1.b + (color2.b - color1.b) * easedProgress), + a: color1.a + (color2.a - color1.a) * easedProgress }; } @@ -244,15 +290,22 @@ export class ColorscapeService { const primaryHSL = this.rgbToHsl(primary); const secondaryHSL = this.rgbToHsl(secondary); - if (isDarkTheme) { - return this.calculateDarkThemeColors(secondaryHSL, primaryHSL, primary); - } else { - // NOTE: Light themes look bad in general with this system. - return this.calculateLightThemeDarkColors(primaryHSL, primary); - } + return isDarkTheme + ? this.calculateDarkThemeColors(secondaryHSL, primaryHSL, primary) + : this.calculateLightThemeDarkColors(primaryHSL, primary); // NOTE: Light themes look bad in general with this system. } - private calculateLightThemeDarkColors(primaryHSL: { h: number; s: number; l: number }, primary: RGB) { + private adjustColorWithVariance(color: string): string { + const rgb = this.hexToRgb(color); + const randomVariance = () => this.getRandomInRange(-10, 10); // Random variance for each color channel + return this.rgbToHex({ + r: Math.min(255, Math.max(0, rgb.r + randomVariance())), + g: Math.min(255, Math.max(0, rgb.g + randomVariance())), + b: Math.min(255, Math.max(0, rgb.b + randomVariance())) + }); + } + + private calculateLightThemeDarkColors(primaryHSL: HSL, primary: RGB) { const lighterHSL = {...primaryHSL}; lighterHSL.s = Math.max(lighterHSL.s - 0.3, 0); lighterHSL.l = Math.min(lighterHSL.l + 0.5, 0.95); @@ -273,7 +326,7 @@ export class ColorscapeService { }; } - private calculateDarkThemeColors(secondaryHSL: { h: number; s: number; l: number }, primaryHSL: { + private calculateDarkThemeColors(secondaryHSL: HSL, primaryHSL: { h: number; s: number; l: number @@ -289,14 +342,62 @@ export class ColorscapeService { complementaryHSL.s = Math.min(complementaryHSL.s + 0.1, 1); complementaryHSL.l = Math.max(complementaryHSL.l - 0.2, 0.2); + // Array of colors to shuffle + const colors = [ + this.rgbToHex(primary), + this.rgbToHex(this.hslToRgb(lighterHSL)), + this.rgbToHex(this.hslToRgb(darkerHSL)), + this.rgbToHex(this.hslToRgb(complementaryHSL)) + ]; + + // Shuffle colors array + this.shuffleArray(colors); + + // Set a brightness threshold (you can adjust this value as needed) + const brightnessThreshold = 100; // Adjust based on your needs (0-255) + + // Ensure the 'lighter' color is not too bright + if (this.getBrightness(colors[1]) > brightnessThreshold) { + // If it is too bright, find a suitable swap + for (let i = 0; i < colors.length; i++) { + if (this.getBrightness(colors[i]) <= brightnessThreshold) { + // Swap colors[1] (lighter) with a less bright color + [colors[1], colors[i]] = [colors[i], colors[1]]; + break; + } + } + } + + // Ensure no color is repeating and variance is maintained + const uniqueColors = new Set(colors); + if (uniqueColors.size < colors.length) { + // If there are duplicates, re-shuffle the array + this.shuffleArray(colors); + } + return { - primary: this.rgbToHex(primary), - lighter: this.rgbToHex(this.hslToRgb(lighterHSL)), - darker: this.rgbToHex(this.hslToRgb(darkerHSL)), - complementary: this.rgbToHex(this.hslToRgb(complementaryHSL)) + primary: colors[0], + lighter: colors[1], + darker: colors[2], + complementary: colors[3] }; } + // Calculate brightness of a color (RGB) + private getBrightness(color: string) { + const rgb = this.hexToRgb(color); // Convert hex to RGB + // Using the luminance formula for brightness + return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b); + } + + // Fisher-Yates shuffle algorithm + private shuffleArray(array: string[]) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + } + private hexToRgb(hex: string): RGB { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { @@ -310,7 +411,7 @@ export class ColorscapeService { return `#${((1 << 24) + (rgb.r << 16) + (rgb.g << 8) + rgb.b).toString(16).slice(1)}`; } - private rgbToHsl(rgb: RGB): { h: number; s: number; l: number } { + private rgbToHsl(rgb: RGB): HSL { const r = rgb.r / 255; const g = rgb.g / 255; const b = rgb.b / 255; @@ -334,7 +435,7 @@ export class ColorscapeService { return { h, s, l }; } - private hslToRgb(hsl: { h: number; s: number; l: number }): RGB { + private hslToRgb(hsl: HSL): RGB { const { h, s, l } = hsl; let r, g, b; @@ -364,7 +465,7 @@ export class ColorscapeService { }; } - private adjustHue(hsl: { h: number; s: number; l: number }, amount: number): { h: number; s: number; l: number } { + private adjustHue(hsl: HSL, amount: number): HSL { return { h: (hsl.h + amount / 360) % 1, s: hsl.s, @@ -404,7 +505,7 @@ export class ColorscapeService { styleElement.textContent = styles; } - private unsetPageColorOverrides() { - Array.from(this.document.head.children).filter(el => el.tagName === 'STYLE' && el.id.toLowerCase() === colorScapeSelector).forEach(c => this.document.head.removeChild(c)); + private getRandomInRange(min: number, max: number): number { + return Math.random() * (max - min) + min; } } diff --git a/UI/Web/src/app/_services/dashboard.service.ts b/UI/Web/src/app/_services/dashboard.service.ts index 9d9deeffb..493fae370 100644 --- a/UI/Web/src/app/_services/dashboard.service.ts +++ b/UI/Web/src/app/_services/dashboard.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import {Injectable} from '@angular/core'; import {TextResonse} from "../_types/text-response"; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; @@ -26,4 +26,8 @@ export class DashboardService { createDashboardStream(smartFilterId: number) { return this.httpClient.post(this.baseUrl + 'stream/add-dashboard-stream?smartFilterId=' + smartFilterId, {}); } + + deleteSmartFilterStream(streamId: number) { + return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-dashboard-stream?dashboardStreamId=' + streamId, {}); + } } diff --git a/UI/Web/src/app/_services/email.service.ts b/UI/Web/src/app/_services/email.service.ts new file mode 100644 index 000000000..5afb62ca7 --- /dev/null +++ b/UI/Web/src/app/_services/email.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {EmailHistory} from "../_models/email-history"; + +@Injectable({ + providedIn: 'root' +}) +export class EmailService { + baseUrl = environment.apiUrl; + constructor(private httpClient: HttpClient) { } + + getEmailHistory() { + return this.httpClient.get(`${this.baseUrl}email/all`); + } +} diff --git a/UI/Web/src/app/_services/external-source.service.ts b/UI/Web/src/app/_services/external-source.service.ts index 0fd726fa6..5fbc5a397 100644 --- a/UI/Web/src/app/_services/external-source.service.ts +++ b/UI/Web/src/app/_services/external-source.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import {environment} from "../../environments/environment"; -import {HttpClient} from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; import {ExternalSource} from "../_models/sidenav/external-source"; import {TextResonse} from "../_types/text-response"; import {map} from "rxjs/operators"; diff --git a/UI/Web/src/app/_services/filter.service.ts b/UI/Web/src/app/_services/filter.service.ts index 7d2648072..2b9681e90 100644 --- a/UI/Web/src/app/_services/filter.service.ts +++ b/UI/Web/src/app/_services/filter.service.ts @@ -1,8 +1,7 @@ -import { Injectable } from '@angular/core'; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {Injectable} from '@angular/core'; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {environment} from "../../environments/environment"; import {HttpClient} from "@angular/common/http"; -import {JumpKey} from "../_models/jumpbar/jump-key"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; @Injectable({ @@ -13,7 +12,7 @@ export class FilterService { baseUrl = environment.apiUrl; constructor(private httpClient: HttpClient) { } - saveFilter(filter: SeriesFilterV2) { + saveFilter(filter: FilterV2) { return this.httpClient.post(this.baseUrl + 'filter/update', filter); } getAllFilters() { @@ -23,4 +22,7 @@ export class FilterService { return this.httpClient.delete(this.baseUrl + 'filter?filterId=' + filterId); } + renameSmartFilter(filter: SmartFilter) { + return this.httpClient.post(this.baseUrl + `filter/rename?filterId=${filter.id}&name=${filter.name.trim()}`, {}); + } } diff --git a/UI/Web/src/app/_services/jumpbar.service.ts b/UI/Web/src/app/_services/jumpbar.service.ts index e4ac0b01f..48ca08705 100644 --- a/UI/Web/src/app/_services/jumpbar.service.ts +++ b/UI/Web/src/app/_services/jumpbar.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@angular/core'; -import { JumpKey } from '../_models/jumpbar/jump-key'; +import {Injectable} from '@angular/core'; +import {JumpKey} from '../_models/jumpbar/jump-key'; const keySize = 25; // Height of the JumpBar button @@ -16,7 +16,8 @@ export class JumpbarService { getResumeKey(key: string) { - if (this.resumeKeys.hasOwnProperty(key)) return this.resumeKeys[key]; + const k = key.toUpperCase(); + if (this.resumeKeys.hasOwnProperty(k)) return this.resumeKeys[k]; return ''; } @@ -26,7 +27,8 @@ export class JumpbarService { } saveResumeKey(key: string, value: string) { - this.resumeKeys[key] = value; + const k = key.toUpperCase(); + this.resumeKeys[k] = value; } saveResumePosition(url: string, value: number) { @@ -75,9 +77,11 @@ export class JumpbarService { _removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array, jumpBarKeysToRender: Array) { const removedIndexes: Array = []; + for(let removal = 0; removal < numberOfRemovals; removal++) { let min = 100000000; let minIndex = -1; + for(let i = 1; i < midPoint; i++) { if (jumpBarKeys[i].size < min && !removedIndexes.includes(i)) { min = jumpBarKeys[i].size; @@ -101,20 +105,25 @@ export class JumpbarService { getJumpKeys(data :Array, keySelector: (data: any) => string) { const keys: {[key: string]: number} = {}; data.forEach(obj => { - let ch = keySelector(obj).charAt(0); - if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { - ch = '#'; + try { + let ch = keySelector(obj).charAt(0).toUpperCase(); + if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { + ch = '#'; + } + if (!keys.hasOwnProperty(ch)) { + keys[ch] = 0; + } + keys[ch] += 1; + } catch (e) { + console.error('Failed to calculate jump key for ', obj, e); } - if (!keys.hasOwnProperty(ch)) { - keys[ch] = 0; - } - keys[ch] += 1; }); return Object.keys(keys).map(k => { + k = k.toUpperCase(); return { key: k, size: keys[k], - title: k.toUpperCase() + title: k } }).sort((a, b) => { if (a.key < b.key) return -1; diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 34c46e154..8c851dd80 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -125,6 +125,13 @@ export class LibraryService { return this.httpClient.delete(this.baseUrl + 'library/delete?libraryId=' + libraryId, {}); } + deleteMultiple(libraryIds: Array) { + if (libraryIds.length === 0) { + return of(); + } + return this.httpClient.delete(this.baseUrl + 'library/delete-multiple?libraryIds=' + libraryIds.join(','), {}); + } + update(model: {name: string, folders: string[], id: number}) { return this.httpClient.post(this.baseUrl + 'library/update', model); } diff --git a/UI/Web/src/app/_services/license.service.ts b/UI/Web/src/app/_services/license.service.ts new file mode 100644 index 000000000..a2e77f2fe --- /dev/null +++ b/UI/Web/src/app/_services/license.service.ts @@ -0,0 +1,83 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {catchError, map, ReplaySubject, tap, throwError} from "rxjs"; +import {environment} from "../../environments/environment"; +import {TextResonse} from '../_types/text-response'; +import {LicenseInfo} from "../_models/kavitaplus/license-info"; + +@Injectable({ + providedIn: 'root' +}) +export class LicenseService { + private readonly httpClient = inject(HttpClient); + + baseUrl = environment.apiUrl; + + private readonly hasValidLicenseSource = new ReplaySubject(1); + /** + * Does the user have an active license + */ + public readonly hasValidLicense$ = this.hasValidLicenseSource.asObservable(); + + + /** + * Delete the license from the server and update hasValidLicenseSource to false + */ + deleteLicense() { + return this.httpClient.delete(this.baseUrl + 'license', TextResonse).pipe( + map(res => res === "true"), + tap(_ => { + this.hasValidLicenseSource.next(false) + }), + catchError(error => { + this.hasValidLicenseSource.next(false); + return throwError(error); // Rethrow the error to propagate it further + }) + ); + } + + resetLicense(license: string, email: string) { + return this.httpClient.post(this.baseUrl + 'license/reset', {license, email}, TextResonse); + } + + /** + * Returns information about License and will internally cache if license is valid or not + */ + licenseInfo(forceCheck: boolean = false) { + return this.httpClient.get(this.baseUrl + `license/info?forceCheck=${forceCheck}`).pipe( + tap(res => { + this.hasValidLicenseSource.next(res?.isActive || false) + }), + catchError(error => { + this.hasValidLicenseSource.next(false); + return throwError(error); // Rethrow the error to propagate it further + }) + ); + } + + hasValidLicense(forceCheck: boolean = false) { + return this.httpClient.get(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse) + .pipe( + map(res => res === "true"), + tap(res => { + this.hasValidLicenseSource.next(res) + }), + catchError(error => { + this.hasValidLicenseSource.next(false); + return throwError(error); // Rethrow the error to propagate it further + }) + ); + } + + hasAnyLicense() { + return this.httpClient.get(this.baseUrl + 'license/has-license', TextResonse) + .pipe( + map(res => res === "true"), + ); + } + + updateUserLicense(license: string, email: string, discordId?: string) { + return this.httpClient.post(this.baseUrl + 'license', {license, email, discordId}, TextResonse) + .pipe(map(res => res === "true")); + } +} diff --git a/UI/Web/src/app/_services/localization.service.ts b/UI/Web/src/app/_services/localization.service.ts index 23ba213b6..7519a9562 100644 --- a/UI/Web/src/app/_services/localization.service.ts +++ b/UI/Web/src/app/_services/localization.service.ts @@ -1,18 +1,36 @@ -import { Injectable } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {environment} from "../../environments/environment"; -import {HttpClient} from "@angular/common/http"; -import {Language} from "../_models/metadata/language"; +import { HttpClient } from "@angular/common/http"; +import {KavitaLocale, Language} from "../_models/metadata/language"; +import {ReplaySubject, tap} from "rxjs"; +import {TranslocoService} from "@jsverse/transloco"; @Injectable({ providedIn: 'root' }) export class LocalizationService { + private readonly translocoService = inject(TranslocoService); + baseUrl = environment.apiUrl; + private readonly localeSubject = new ReplaySubject(1); + public readonly locales$ = this.localeSubject.asObservable(); + constructor(private httpClient: HttpClient) { } getLocales() { - return this.httpClient.get(this.baseUrl + 'locale'); + return this.httpClient.get(this.baseUrl + 'locale').pipe(tap(locales => { + this.localeSubject.next(locales); + })); + } + + refreshTranslations(lang: string) { + + // Clear the cached translation + localStorage.removeItem(`@@TRANSLOCO_PERSIST_TRANSLATIONS/${lang}`); + + // Reload the translation + return this.translocoService.load(lang); } } diff --git a/UI/Web/src/app/_services/manage.service.ts b/UI/Web/src/app/_services/manage.service.ts new file mode 100644 index 000000000..781830caa --- /dev/null +++ b/UI/Web/src/app/_services/manage.service.ts @@ -0,0 +1,18 @@ +import {inject, Injectable} from '@angular/core'; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {ManageMatchSeries} from "../_models/kavitaplus/manage-match-series"; +import {ManageMatchFilter} from "../_models/kavitaplus/manage-match-filter"; + +@Injectable({ + providedIn: 'root' +}) +export class ManageService { + + baseUrl = environment.apiUrl; + private readonly httpClient = inject(HttpClient); + + getAllKavitaPlusSeries(filter: ManageMatchFilter) { + return this.httpClient.post>(this.baseUrl + `manage/series-metadata`, filter); + } +} diff --git a/UI/Web/src/app/_services/member.service.ts b/UI/Web/src/app/_services/member.service.ts index b6afbd8a9..d93098995 100644 --- a/UI/Web/src/app/_services/member.service.ts +++ b/UI/Web/src/app/_services/member.service.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; import { Member } from '../_models/auth/member'; +import {UserTokenInfo} from "../_models/kavitaplus/user-token-info"; @Injectable({ providedIn: 'root' @@ -20,6 +21,10 @@ export class MemberService { return this.httpClient.get(this.baseUrl + 'users/names'); } + getUserTokenInfo() { + return this.httpClient.get(this.baseUrl + 'users/tokens'); + } + adminExists() { return this.httpClient.get(this.baseUrl + 'admin/exists'); } @@ -37,11 +42,11 @@ export class MemberService { } addSeriesToWantToRead(seriesIds: Array) { - return this.httpClient.post>(this.baseUrl + 'want-to-read/add-series', {seriesIds}); + return this.httpClient.post(this.baseUrl + 'want-to-read/add-series', {seriesIds}); } removeSeriesToWantToRead(seriesIds: Array) { - return this.httpClient.post>(this.baseUrl + 'want-to-read/remove-series', {seriesIds}); + return this.httpClient.post(this.baseUrl + 'want-to-read/remove-series', {seriesIds}); } getMember() { diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index ea1819bd7..f870d1449 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -1,15 +1,16 @@ -import { Injectable } from '@angular/core'; -import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; -import { BehaviorSubject, ReplaySubject } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { LibraryModifiedEvent } from '../_models/events/library-modified-event'; -import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; -import { ThemeProgressEvent } from '../_models/events/theme-progress-event'; -import { UserUpdateEvent } from '../_models/events/user-update-event'; -import { User } from '../_models/user'; +import {Injectable} from '@angular/core'; +import {HubConnection, HubConnectionBuilder} from '@microsoft/signalr'; +import {BehaviorSubject, ReplaySubject} from 'rxjs'; +import {environment} from 'src/environments/environment'; +import {LibraryModifiedEvent} from '../_models/events/library-modified-event'; +import {NotificationProgressEvent} from '../_models/events/notification-progress-event'; +import {ThemeProgressEvent} from '../_models/events/theme-progress-event'; +import {UserUpdateEvent} from '../_models/events/user-update-event'; +import {User} from '../_models/user'; import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event"; import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; +import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', @@ -109,7 +110,15 @@ export enum EVENTS { /** * A Progress event when a smart collection is synchronizing */ - SmartCollectionSync = 'SmartCollectionSync' + SmartCollectionSync = 'SmartCollectionSync', + /** + * A Person merged has been merged into another + */ + PersonMerged = 'PersonMerged', + /** + * A Rate limit error was hit when matching a series with Kavita+ + */ + ExternalMatchRateLimitError = 'ExternalMatchRateLimitError' } export interface Message { @@ -232,6 +241,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.ExternalMatchRateLimitError, resp => { + this.messagesSource.next({ + event: EVENTS.ExternalMatchRateLimitError, + payload: resp.body as ExternalMatchRateLimitErrorEvent + }); + }); + this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => { this.messagesSource.next({ event: EVENTS.NotificationProgress, @@ -336,6 +352,13 @@ export class MessageHubService { payload: resp.body }); }); + + this.hubConnection.on(EVENTS.PersonMerged, resp => { + this.messagesSource.next({ + event: EVENTS.PersonMerged, + payload: resp.body + }); + }) } stopHubConnection() { diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 1fe171f66..fe0702219 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -1,31 +1,54 @@ -import {HttpClient} from '@angular/common/http'; -import {Injectable} from '@angular/core'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {inject, Injectable} from '@angular/core'; import {tap} from 'rxjs/operators'; -import {of} from 'rxjs'; +import {map, of} from 'rxjs'; import {environment} from 'src/environments/environment'; import {Genre} from '../_models/metadata/genre'; import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; import {Language} from '../_models/metadata/language'; import {PublicationStatusDto} from '../_models/metadata/publication-status-dto'; -import {Person, PersonRole} from '../_models/metadata/person'; +import {allPeopleRoles, Person, PersonRole} from '../_models/metadata/person'; import {Tag} from '../_models/tag'; import {FilterComparison} from '../_models/metadata/v2/filter-comparison'; import {FilterField} from '../_models/metadata/v2/filter-field'; -import {SortField} from "../_models/metadata/series-filter"; +import {mangaFormatFilters, SortField} from "../_models/metadata/series-filter"; import {FilterCombination} from "../_models/metadata/v2/filter-combination"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {FilterStatement} from "../_models/metadata/v2/filter-statement"; import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus"; import {LibraryType} from "../_models/library/library"; import {IHasCast} from "../_models/common/i-has-cast"; +import {TextResonse} from "../_types/text-response"; +import {QueryContext} from "../_models/metadata/v2/query-context"; +import {AgeRatingPipe} from "../_pipes/age-rating.pipe"; +import {MangaFormatPipe} from "../_pipes/manga-format.pipe"; +import {TranslocoService} from "@jsverse/transloco"; +import {LibraryService} from './library.service'; +import {CollectionTagService} from "./collection-tag.service"; +import {PaginatedResult} from "../_models/pagination"; +import {UtilityService} from "../shared/_services/utility.service"; +import {BrowseGenre} from "../_models/metadata/browse/browse-genre"; +import {BrowseTag} from "../_models/metadata/browse/browse-tag"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {PersonRolePipe} from "../_pipes/person-role.pipe"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Injectable({ providedIn: 'root' }) export class MetadataService { + private readonly translocoService = inject(TranslocoService); + private readonly libraryService = inject(LibraryService); + private readonly collectionTagService = inject(CollectionTagService); + private readonly utilityService = inject(UtilityService); + baseUrl = environment.apiUrl; private validLanguages: Array = []; + private ageRatingPipe = new AgeRatingPipe(); + private mangaFormatPipe = new MangaFormatPipe(this.translocoService); + private personRolePipe = new PersonRolePipe(); constructor(private httpClient: HttpClient) { } @@ -61,14 +84,39 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + method); } - getAllGenres(libraries?: Array) { + getAllGenres(libraries?: Array, context: QueryContext = QueryContext.None) { let method = 'metadata/genres' if (libraries != undefined && libraries.length > 0) { - method += '?libraryIds=' + libraries.join(','); + method += '?libraryIds=' + libraries.join(',') + '&context=' + context; + } else { + method += '?context=' + context; } + return this.httpClient.get>(this.baseUrl + method); } + getGenreWithCounts(pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'metadata/genres-with-counts', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } + + getTagWithCounts(pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'metadata/tags-with-counts', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } + getAllLanguages(libraries?: Array) { let method = 'metadata/languages' if (libraries != undefined && libraries.length > 0) { @@ -77,6 +125,10 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + method); } + getLanguageNameForCode(code: string) { + return this.httpClient.get(`${this.baseUrl}metadata/language-title?code=${code}`, TextResonse); + } + /** * All the potential language tags there can be @@ -101,19 +153,28 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + 'metadata/people-by-role?role=' + role); } - createDefaultFilterDto(): SeriesFilterV2 { + createDefaultFilterDto(entityType: ValidFilterEntity): FilterV2 { return { - statements: [] as FilterStatement[], + statements: [] as FilterStatement[], combination: FilterCombination.And, limitTo: 0, sortOptions: { isAscending: true, - sortField: SortField.SortName + sortField: (entityType === 'series' ? SortField.SortName : PersonSortField.Name) as TSort } }; } - createDefaultFilterStatement(field: FilterField = FilterField.SeriesName, comparison = FilterComparison.Equal, value = '') { + createDefaultFilterStatement(entityType: ValidFilterEntity) { + switch (entityType) { + case 'series': + return this.createFilterStatement(FilterField.SeriesName); + case 'person': + return this.createFilterStatement(PersonFilterField.Role, FilterComparison.Contains, `${PersonRole.CoverArtist},${PersonRole.Writer}`); + } + } + + createFilterStatement(field: T, comparison = FilterComparison.Equal, value = '') { return { comparison: comparison, field: field, @@ -121,7 +182,7 @@ export class MetadataService { }; } - updateFilter(arr: Array, index: number, filterStmt: FilterStatement) { + updateFilter(arr: Array>, index: number, filterStmt: FilterStatement) { arr[index].comparison = filterStmt.comparison; arr[index].field = filterStmt.field; arr[index].value = filterStmt.value ? filterStmt.value + '' : ''; @@ -131,8 +192,6 @@ export class MetadataService { switch (role) { case PersonRole.Other: break; - case PersonRole.Artist: - break; case PersonRole.CoverArtist: entity.coverArtists = persons; break; @@ -174,4 +233,85 @@ export class MetadataService { break; } } + + /** + * Used to get the underlying Options (for Metadata Filter Dropdowns) + * @param filterField + * @param entityType + */ + getOptionsForFilterField(filterField: T, entityType: ValidFilterEntity) { + + switch (entityType) { + case 'series': + return this.getSeriesOptionsForFilterField(filterField as FilterField); + case 'person': + return this.getPersonOptionsForFilterField(filterField as PersonFilterField); + } + } + + private getPersonOptionsForFilterField(field: PersonFilterField) { + switch (field) { + case PersonFilterField.Role: + return of(allPeopleRoles.map(r => {return {value: r, label: this.personRolePipe.transform(r)}})); + } + return of([]) + } + + private getSeriesOptionsForFilterField(field: FilterField) { + switch (field) { + case FilterField.PublicationStatus: + return this.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => { + return {value: pub.value, label: pub.title} + }))); + case FilterField.AgeRating: + return this.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => { + return {value: rating.value, label: this.ageRatingPipe.transform(rating.value)} + }))); + case FilterField.Genres: + return this.getAllGenres().pipe(map(genres => genres.map(genre => { + return {value: genre.id, label: genre.title} + }))); + case FilterField.Languages: + return this.getAllLanguages().pipe(map(statuses => statuses.map(status => { + return {value: status.isoCode, label: status.title + ` (${status.isoCode})`} + }))); + case FilterField.Formats: + return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => { + return {value: status.value, label: this.mangaFormatPipe.transform(status.value)} + }))); + case FilterField.Libraries: + return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => { + return {value: lib.id, label: lib.name} + }))); + case FilterField.Tags: + return this.getAllTags().pipe(map(statuses => statuses.map(status => { + return {value: status.id, label: status.title} + }))); + case FilterField.CollectionTags: + return this.collectionTagService.allCollections().pipe(map(statuses => statuses.map(status => { + return {value: status.id, label: status.title} + }))); + case FilterField.Characters: return this.getPersonOptions(PersonRole.Character); + case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist); + case FilterField.CoverArtist: return this.getPersonOptions(PersonRole.CoverArtist); + case FilterField.Editor: return this.getPersonOptions(PersonRole.Editor); + case FilterField.Inker: return this.getPersonOptions(PersonRole.Inker); + case FilterField.Letterer: return this.getPersonOptions(PersonRole.Letterer); + case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller); + case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher); + case FilterField.Imprint: return this.getPersonOptions(PersonRole.Imprint); + case FilterField.Team: return this.getPersonOptions(PersonRole.Team); + case FilterField.Location: return this.getPersonOptions(PersonRole.Location); + case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator); + case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer); + } + + return of([]); + } + + private getPersonOptions(role: PersonRole) { + return this.getAllPeopleByRole(role).pipe(map(people => people.map(person => { + return {value: person.id, label: person.name} + }))); + } } diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 2bec44706..0aad76ef7 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -1,6 +1,6 @@ import {DOCUMENT} from '@angular/common'; import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core'; -import {distinctUntilChanged, filter, ReplaySubject, take} from 'rxjs'; +import {filter, ReplaySubject, take} from 'rxjs'; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; @@ -9,6 +9,24 @@ import {AccountService} from "./account.service"; import {map} from "rxjs/operators"; import {NavigationEnd, Router} from "@angular/router"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; +import {WikiLink} from "../_models/wiki"; + +/** + * NavItem used to construct the dropdown or NavLinkModal on mobile + * Priority construction + * @param routerLink A link to a page on the web app, takes priority + * @param fragment Optional fragment for routerLink + * @param href A link to an external page, must set noopener noreferrer + * @param click Callback, lowest priority. Should only be used if routerLink and href or not set + */ +interface NavItem { + transLocoKey: string; + href?: string; + fragment?: string; + routerLink?: string; + click?: () => void; +} @Injectable({ providedIn: 'root' @@ -21,6 +39,33 @@ export class NavService { public localStorageSideNavKey = 'kavita--sidenav--expanded'; + public navItems: NavItem[] = [ + { + transLocoKey: 'all-filters', + routerLink: '/all-filters/', + }, + { + transLocoKey: 'browse-genres', + routerLink: '/browse/genres', + }, + { + transLocoKey: 'browse-tags', + routerLink: '/browse/tags', + }, + { + transLocoKey: 'announcements', + routerLink: '/announcements/', + }, + { + transLocoKey: 'help', + href: WikiLink.Guides, + }, + { + transLocoKey: 'logout', + click: () => this.logout(), + } + ] + private navbarVisibleSource = new ReplaySubject(1); /** * If the top Nav bar is rendered or not @@ -93,6 +138,10 @@ export class NavService { return this.httpClient.post(this.baseUrl + 'stream/bulk-sidenav-stream-visibility', {ids: streamIds, visibility: targetVisibility}); } + deleteSideNavSmartFilter(streamId: number) { + return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-side-nav-stream?sideNavStreamId=' + streamId, {}); + } + /** * Shows the top nav bar. This should be visible on all pages except the reader. */ @@ -123,6 +172,13 @@ export class NavService { }, 10); } + logout() { + this.accountService.logout(); + this.hideNavBar(); + this.hideSideNav(); + this.router.navigateByUrl('/login'); + } + /** * Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state. */ diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index cc83e2a50..fc9148135 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -1,15 +1,17 @@ -import { Injectable } from '@angular/core'; +import {Injectable} from '@angular/core'; import {HttpClient, HttpParams} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {Person, PersonRole} from "../_models/metadata/person"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {PaginatedResult} from "../_models/pagination"; import {Series} from "../_models/series"; import {map} from "rxjs/operators"; import {UtilityService} from "../shared/_services/utility.service"; -import {BrowsePerson} from "../_models/person/browse-person"; -import {Chapter} from "../_models/chapter"; +import {BrowsePerson} from "../_models/metadata/browse/browse-person"; import {StandaloneChapter} from "../_models/standalone-chapter"; +import {TextResonse} from "../_types/text-response"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Injectable({ providedIn: 'root' @@ -25,11 +27,15 @@ export class PersonService { } get(name: string) { - return this.httpClient.get(this.baseUrl + `person?name=${name}`); + return this.httpClient.get(this.baseUrl + `person?name=${name}`); } - getRolesForPerson(name: string) { - return this.httpClient.get>(this.baseUrl + `person/roles?name=${name}`); + searchPerson(name: string) { + return this.httpClient.get>(this.baseUrl + `person/search?queryString=${encodeURIComponent(name)}`); + } + + getRolesForPerson(personId: number) { + return this.httpClient.get>(this.baseUrl + `person/roles?personId=${personId}`); } getSeriesMostKnownFor(personId: number) { @@ -40,14 +46,40 @@ export class PersonService { return this.httpClient.get>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`); } - getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) { + getAuthorsToBrowse(filter: FilterV2, pageNum?: number, itemsPerPage?: number) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); - return this.httpClient.post>(this.baseUrl + 'person/authors', {}, {observe: 'response', params}).pipe( + return this.httpClient.post>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe( map((response: any) => { return this.utilityService.createPaginatedResult(response) as PaginatedResult; }) ); } + + // getAuthorsToBrowse(filter: BrowsePersonFilter, pageNum?: number, itemsPerPage?: number) { + // let params = new HttpParams(); + // params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + // + // return this.httpClient.post>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe( + // map((response: any) => { + // return this.utilityService.createPaginatedResult(response) as PaginatedResult; + // }) + // ); + // } + + downloadCover(personId: number) { + return this.httpClient.post(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); + } + + isValidAlias(personId: number, alias: string) { + return this.httpClient.get(this.baseUrl + `person/valid-alias?personId=${personId}&alias=${alias}`, TextResonse).pipe( + map(valid => valid + '' === 'true') + ); + } + + mergePerson(destId: number, srcId: number) { + return this.httpClient.post(this.baseUrl + 'person/merge', {destId, srcId}); + } + } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 991a37bdb..52aef2a4a 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -1,28 +1,29 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import {DestroyRef, Inject, inject, Injectable} from '@angular/core'; import {DOCUMENT, Location} from '@angular/common'; -import { Router } from '@angular/router'; -import { environment } from 'src/environments/environment'; -import { ChapterInfo } from '../manga-reader/_models/chapter-info'; -import { Chapter } from '../_models/chapter'; -import { HourEstimateRange } from '../_models/series-detail/hour-estimate-range'; -import { MangaFormat } from '../_models/manga-format'; -import { BookmarkInfo } from '../_models/manga-reader/bookmark-info'; -import { PageBookmark } from '../_models/readers/page-bookmark'; -import { ProgressBookmark } from '../_models/readers/progress-bookmark'; -import { FileDimension } from '../manga-reader/_models/file-dimension'; +import {Router} from '@angular/router'; +import {environment} from 'src/environments/environment'; +import {ChapterInfo} from '../manga-reader/_models/chapter-info'; +import {Chapter} from '../_models/chapter'; +import {HourEstimateRange} from '../_models/series-detail/hour-estimate-range'; +import {MangaFormat} from '../_models/manga-format'; +import {BookmarkInfo} from '../_models/manga-reader/bookmark-info'; +import {PageBookmark} from '../_models/readers/page-bookmark'; +import {ProgressBookmark} from '../_models/readers/progress-bookmark'; +import {FileDimension} from '../manga-reader/_models/file-dimension'; import screenfull from 'screenfull'; -import { TextResonse } from '../_types/text-response'; -import { AccountService } from './account.service'; +import {TextResonse} from '../_types/text-response'; +import {AccountService} from './account.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {PersonalToC} from "../_models/readers/personal-toc"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import NoSleep from 'nosleep.js'; import {FullProgress} from "../_models/readers/full-progress"; import {Volume} from "../_models/volume"; import {UtilityService} from "../shared/_services/utility.service"; import {translate} from "@jsverse/transloco"; import {ToastrService} from "ngx-toastr"; +import {FilterField} from "../_models/metadata/v2/filter-field"; export const CHAPTER_ID_DOESNT_EXIST = -1; @@ -46,7 +47,8 @@ export class ReaderService { // Override background color for reader and restore it onDestroy private originalBodyColor!: string; - private noSleep = new NoSleep(); + + private noSleep: NoSleep = new NoSleep(); constructor(private httpClient: HttpClient, @Inject(DOCUMENT) private document: Document) { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { @@ -56,17 +58,18 @@ export class ReaderService { }); } + enableWakeLock(element?: Element | Document) { // Enable wake lock. // (must be wrapped in a user input event handler e.g. a mouse or touch handler) if (!element) element = this.document; - const enableNoSleepHandler = () => { + const enableNoSleepHandler = async () => { element!.removeEventListener('click', enableNoSleepHandler, false); element!.removeEventListener('touchmove', enableNoSleepHandler, false); element!.removeEventListener('mousemove', enableNoSleepHandler, false); - this.noSleep!.enable(); + await this.noSleep.enable(); }; // Enable wake lock. @@ -105,7 +108,7 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page}); } - getAllBookmarks(filter: SeriesFilterV2 | undefined) { + getAllBookmarks(filter: FilterV2 | undefined) { return this.httpClient.post(this.baseUrl + 'reader/all-bookmarks', filter); } @@ -263,13 +266,13 @@ export class ReaderService { getQueryParamsObject(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) { - let params: {[key: string]: any} = {}; - if (incognitoMode) { - params['incognitoMode'] = true; - } + const params: {[key: string]: any} = {}; + params['incognitoMode'] = incognitoMode; + if (readingListMode) { params['readingListId'] = readingListId; } + return params; } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 04d97060f..088263a33 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -1,13 +1,13 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { UtilityService } from '../shared/_services/utility.service'; -import { Person } from '../_models/metadata/person'; -import { PaginatedResult } from '../_models/pagination'; -import { ReadingList, ReadingListItem } from '../_models/reading-list'; -import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary'; -import { TextResonse } from '../_types/text-response'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {UtilityService} from '../shared/_services/utility.service'; +import {Person, PersonRole} from '../_models/metadata/person'; +import {PaginatedResult} from '../_models/pagination'; +import {ReadingList, ReadingListCast, ReadingListInfo, ReadingListItem} from '../_models/reading-list'; +import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary'; +import {TextResonse} from '../_types/text-response'; import {Action, ActionItem} from './action-factory.service'; @Injectable({ @@ -20,7 +20,7 @@ export class ReadingListService { constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } getReadingList(readingListId: number) { - return this.httpClient.get(this.baseUrl + 'readinglist?readingListId=' + readingListId); + return this.httpClient.get(this.baseUrl + 'readinglist?readingListId=' + readingListId); } getReadingLists(includePromoted: boolean = true, sortByLastModified: boolean = false, pageNum?: number, itemsPerPage?: number) { @@ -114,10 +114,20 @@ export class ReadingListService { return this.httpClient.post(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); } - getCharacters(readingListId: number) { - return this.httpClient.get>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId); + getPeople(readingListId: number, role: PersonRole) { + return this.httpClient.get>(this.baseUrl + `readinglist/people?readingListId=${readingListId}&role=${role}`); } + getAllPeople(readingListId: number) { + return this.httpClient.get(this.baseUrl + `readinglist/all-people?readingListId=${readingListId}`); + } + + + getReadingListInfo(readingListId: number) { + return this.httpClient.get(this.baseUrl + `readinglist/info?readingListId=${readingListId}`); + } + + promoteMultipleReadingLists(listIds: Array, promoted: boolean) { return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse); } diff --git a/UI/Web/src/app/_services/reading-profile.service.ts b/UI/Web/src/app/_services/reading-profile.service.ts new file mode 100644 index 000000000..e8be8b6ab --- /dev/null +++ b/UI/Web/src/app/_services/reading-profile.service.ts @@ -0,0 +1,70 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {environment} from "../../environments/environment"; +import {ReadingProfile} from "../_models/preferences/reading-profiles"; + +@Injectable({ + providedIn: 'root' +}) +export class ReadingProfileService { + + private readonly httpClient = inject(HttpClient); + baseUrl = environment.apiUrl; + + getForSeries(seriesId: number, skipImplicit: boolean = false) { + return this.httpClient.get(this.baseUrl + `reading-profile/${seriesId}?skipImplicit=${skipImplicit}`); + } + + getForLibrary(libraryId: number) { + return this.httpClient.get(this.baseUrl + `reading-profile/library?libraryId=${libraryId}`); + } + + updateProfile(profile: ReadingProfile) { + return this.httpClient.post(this.baseUrl + 'reading-profile', profile); + } + + updateParentProfile(seriesId: number, profile: ReadingProfile) { + return this.httpClient.post(this.baseUrl + `reading-profile/update-parent?seriesId=${seriesId}`, profile); + } + + createProfile(profile: ReadingProfile) { + return this.httpClient.post(this.baseUrl + 'reading-profile/create', profile); + } + + promoteProfile(profileId: number) { + return this.httpClient.post(this.baseUrl + "reading-profile/promote?profileId=" + profileId, {}); + } + + updateImplicit(profile: ReadingProfile, seriesId: number) { + return this.httpClient.post(this.baseUrl + "reading-profile/series?seriesId="+seriesId, profile); + } + + getAllProfiles() { + return this.httpClient.get(this.baseUrl + 'reading-profile/all'); + } + + delete(id: number) { + return this.httpClient.delete(this.baseUrl + `reading-profile?profileId=${id}`); + } + + addToSeries(id: number, seriesId: number) { + return this.httpClient.post(this.baseUrl + `reading-profile/series/${seriesId}?profileId=${id}`, {}); + } + + clearSeriesProfiles(seriesId: number) { + return this.httpClient.delete(this.baseUrl + `reading-profile/series/${seriesId}`, {}); + } + + addToLibrary(id: number, libraryId: number) { + return this.httpClient.post(this.baseUrl + `reading-profile/library/${libraryId}?profileId=${id}`, {}); + } + + clearLibraryProfiles(libraryId: number) { + return this.httpClient.delete(this.baseUrl + `reading-profile/library/${libraryId}`, {}); + } + + bulkAddToSeries(id: number, seriesIds: number[]) { + return this.httpClient.post(this.baseUrl + `reading-profile/bulk?profileId=${id}`, seriesIds); + } + +} diff --git a/UI/Web/src/app/_services/review.service.ts b/UI/Web/src/app/_services/review.service.ts new file mode 100644 index 000000000..b8635bcf8 --- /dev/null +++ b/UI/Web/src/app/_services/review.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import {UserReview} from "../_single-module/review-card/user-review"; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {Rating} from "../_models/rating"; + +@Injectable({ + providedIn: 'root' +}) +export class ReviewService { + + private baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient) { } + + deleteReview(seriesId: number, chapterId?: number) { + if (chapterId) { + return this.httpClient.delete(this.baseUrl + `review/chapter?chapterId=${chapterId}`); + } + + return this.httpClient.delete(this.baseUrl + `review/series?seriesId=${seriesId}`); + } + + updateReview(seriesId: number, body: string, chapterId?: number) { + if (chapterId) { + return this.httpClient.post(this.baseUrl + `review/chapter`, { + seriesId, chapterId, body + }); + } + + return this.httpClient.post(this.baseUrl + 'review/series', { + seriesId, body + }); + } + + updateRating(seriesId: number, userRating: number, chapterId?: number) { + if (chapterId) { + return this.httpClient.post(this.baseUrl + 'rating/chapter', { + seriesId, chapterId, userRating + }) + } + + return this.httpClient.post(this.baseUrl + 'rating/series', { + seriesId, userRating + }) + } + + overallRating(seriesId: number, chapterId?: number) { + if (chapterId) { + return this.httpClient.get(this.baseUrl + `rating/overall-chapter?chapterId=${chapterId}`); + } + + return this.httpClient.get(this.baseUrl + `rating/overall-series?seriesId=${seriesId}`); + } + +} diff --git a/UI/Web/src/app/_services/scrobbling.service.ts b/UI/Web/src/app/_services/scrobbling.service.ts index c77a66ba3..cfc7b34ac 100644 --- a/UI/Web/src/app/_services/scrobbling.service.ts +++ b/UI/Web/src/app/_services/scrobbling.service.ts @@ -1,8 +1,8 @@ import {HttpClient, HttpParams} from '@angular/common/http'; import {Injectable} from '@angular/core'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { TextResonse } from '../_types/text-response'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {TextResonse} from '../_types/text-response'; import {ScrobbleError} from "../_models/scrobbling/scrobble-error"; import {ScrobbleEvent} from "../_models/scrobbling/scrobble-event"; import {ScrobbleHold} from "../_models/scrobbling/scrobble-hold"; @@ -12,9 +12,10 @@ import {UtilityService} from "../shared/_services/utility.service"; export enum ScrobbleProvider { Kavita = 0, - AniList= 1, + AniList = 1, Mal = 2, - GoogleBooks = 3 + GoogleBooks = 3, + Cbr = 4 } @Injectable({ @@ -32,12 +33,20 @@ export class ScrobblingService { .pipe(map(r => r === "true")); } + /** + * Returns if the token was new or not + */ updateAniListToken(token: string) { - return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token}); + return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token}, TextResonse) + .pipe(map(r => r + '' === 'true')); } + /** + * Returns if the token was new or not + */ updateMalToken(username: string, accessToken: string) { - return this.httpClient.post(this.baseUrl + 'scrobbling/update-mal-token', {username, accessToken}); + return this.httpClient.post(this.baseUrl + 'scrobbling/update-mal-token', {username, accessToken}, TextResonse) + .pipe(map(r => r + '' === 'true')); } getAniListToken() { @@ -48,6 +57,11 @@ export class ScrobblingService { return this.httpClient.get<{username: string, accessToken: string}>(this.baseUrl + 'scrobbling/mal-token'); } + + hasRunScrobbleGen() { + return this.httpClient.get(this.baseUrl + 'scrobbling/has-ran-scrobble-gen ', TextResonse).pipe(map(r => r === 'true')); + } + getScrobbleErrors() { return this.httpClient.get>(this.baseUrl + 'scrobbling/scrobble-errors'); } @@ -87,4 +101,13 @@ export class ScrobblingService { removeHold(seriesId: number) { return this.httpClient.delete(this.baseUrl + 'scrobbling/remove-hold?seriesId=' + seriesId, TextResonse); } + + triggerScrobbleEventGeneration() { + return this.httpClient.post(this.baseUrl + 'scrobbling/generate-scrobble-events', TextResonse); + } + + bulkRemoveEvents(eventIds: number[]) { + return this.httpClient.post(this.baseUrl + "scrobbling/bulk-remove-events", eventIds) + } + } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index ba3fadde1..9c436e636 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -1,26 +1,26 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { UtilityService } from '../shared/_services/utility.service'; -import { Chapter } from '../_models/chapter'; -import { PaginatedResult } from '../_models/pagination'; -import { Series } from '../_models/series'; -import { RelatedSeries } from '../_models/series-detail/related-series'; -import { SeriesDetail } from '../_models/series-detail/series-detail'; -import { SeriesGroup } from '../_models/series-group'; -import { SeriesMetadata } from '../_models/metadata/series-metadata'; -import { Volume } from '../_models/volume'; -import { ImageService } from './image.service'; -import { TextResonse } from '../_types/text-response'; -import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2'; -import {UserReview} from "../_single-module/review-card/user-review"; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {UtilityService} from '../shared/_services/utility.service'; +import {Chapter} from '../_models/chapter'; +import {PaginatedResult} from '../_models/pagination'; +import {Series} from '../_models/series'; +import {RelatedSeries} from '../_models/series-detail/related-series'; +import {SeriesDetail} from '../_models/series-detail/series-detail'; +import {SeriesGroup} from '../_models/series-group'; +import {SeriesMetadata} from '../_models/metadata/series-metadata'; +import {Volume} from '../_models/volume'; +import {TextResonse} from '../_types/text-response'; +import {FilterV2} from '../_models/metadata/v2/filter-v2'; import {Rating} from "../_models/rating"; import {Recommendation} from "../_models/series-detail/recommendation"; import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail"; import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter"; import {QueryContext} from "../_models/metadata/v2/query-context"; +import {ExternalSeriesMatch} from "../_models/series-detail/external-series-match"; +import {FilterField} from "../_models/metadata/v2/filter-field"; @Injectable({ providedIn: 'root' @@ -31,10 +31,9 @@ export class SeriesService { paginatedResults: PaginatedResult = new PaginatedResult(); paginatedSeriesForTagsResults: PaginatedResult = new PaginatedResult(); - constructor(private httpClient: HttpClient, private imageService: ImageService, - private utilityService: UtilityService) { } + constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } - getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2, context: QueryContext = QueryContext.None) { + getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2, context: QueryContext = QueryContext.None) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -46,7 +45,7 @@ export class SeriesService { ); } - getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -82,10 +81,6 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'series/delete-multiple', {seriesIds}, TextResonse).pipe(map(s => s === "true")); } - updateRating(seriesId: number, userRating: number) { - return this.httpClient.post(this.baseUrl + 'series/update-rating', {seriesId, userRating}); - } - updateSeries(model: any) { return this.httpClient.post(this.baseUrl + 'series/update', model); } @@ -98,7 +93,7 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'reader/mark-unread', {seriesId}); } - getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); @@ -114,7 +109,7 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'series/recently-updated-series', {}); } - getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2): Observable> { + getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: FilterV2): Observable> { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -132,7 +127,7 @@ export class SeriesService { })); } - getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -201,27 +196,9 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/series-detail?seriesId=' + seriesId); } - - - deleteReview(seriesId: number) { - return this.httpClient.delete(this.baseUrl + 'review?seriesId=' + seriesId); - } - updateReview(seriesId: number, body: string) { - return this.httpClient.post(this.baseUrl + 'review', { - seriesId, body - }); - } - - getReviews(seriesId: number) { - return this.httpClient.get>(this.baseUrl + 'review?seriesId=' + seriesId); - } - getRatings(seriesId: number) { return this.httpClient.get>(this.baseUrl + 'rating?seriesId=' + seriesId); } - getOverallRating(seriesId: number) { - return this.httpClient.get(this.baseUrl + 'rating/overall?seriesId=' + seriesId); - } removeFromOnDeck(seriesId: number) { return this.httpClient.post(this.baseUrl + 'series/remove-from-on-deck?seriesId=' + seriesId, {}); @@ -235,4 +212,15 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/next-expected?seriesId=' + seriesId); } + matchSeries(model: any) { + return this.httpClient.post>(this.baseUrl + 'series/match', model); + } + + updateMatch(seriesId: number, series: ExternalSeriesDetail) { + return this.httpClient.post(this.baseUrl + `series/update-match?seriesId=${seriesId}&aniListId=${series.aniListId || 0}&malId=${series.malId || 0}&cbrId=${series.cbrId || 0}`, {}, TextResonse); + } + + updateDontMatch(seriesId: number, dontMatch: boolean) { + return this.httpClient.post(this.baseUrl + `series/dont-match?seriesId=${seriesId}&dontMatch=${dontMatch}`, {}, TextResonse); + } } diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index 6e635c472..4a71e836e 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -41,10 +41,6 @@ export class ServerService { return this.http.post(this.baseUrl + 'server/backup-db', {}); } - analyzeFiles() { - return this.http.post(this.baseUrl + 'server/analyze-files', {}); - } - syncThemes() { return this.http.post(this.baseUrl + 'server/sync-themes', {}); } @@ -53,17 +49,13 @@ export class ServerService { return this.http.get(this.baseUrl + 'server/check-update'); } - checkHowOutOfDate() { - return this.http.get(this.baseUrl + 'server/checkHowOutOfDate', TextResonse) + checkHowOutOfDate(stableOnly: boolean = true) { + return this.http.get(this.baseUrl + `server/check-out-of-date?stableOnly=${stableOnly}`, TextResonse) .pipe(map(r => parseInt(r, 10))); } - checkForUpdates() { - return this.http.get(this.baseUrl + 'server/check-for-updates', {}); - } - - getChangelog() { - return this.http.get(this.baseUrl + 'server/changelog', {}); + getChangelog(count: number = 0) { + return this.http.get(this.baseUrl + 'server/changelog?count=' + count, {}); } getRecurringJobs() { diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 1a0984f2b..cf80765f2 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -1,20 +1,19 @@ -import { HttpClient } from '@angular/common/http'; +import {HttpClient, HttpParams} from '@angular/common/http'; import {Inject, inject, Injectable} from '@angular/core'; -import { environment } from 'src/environments/environment'; -import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; -import { PublicationStatusPipe } from '../_pipes/publication-status.pipe'; -import {asyncScheduler, finalize, map, tap} from 'rxjs'; -import { MangaFormatPipe } from '../_pipes/manga-format.pipe'; -import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; -import { TopUserRead } from '../statistics/_models/top-reads'; -import { ReadHistoryEvent } from '../statistics/_models/read-history-event'; -import { ServerStatistics } from '../statistics/_models/server-statistics'; -import { StatCount } from '../statistics/_models/stat-count'; -import { PublicationStatus } from '../_models/metadata/publication-status'; -import { MangaFormat } from '../_models/manga-format'; -import { TextResonse } from '../_types/text-response'; +import {environment} from 'src/environments/environment'; +import {UserReadStatistics} from '../statistics/_models/user-read-statistics'; +import {PublicationStatusPipe} from '../_pipes/publication-status.pipe'; +import {asyncScheduler, map} from 'rxjs'; +import {MangaFormatPipe} from '../_pipes/manga-format.pipe'; +import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown'; +import {TopUserRead} from '../statistics/_models/top-reads'; +import {ReadHistoryEvent} from '../statistics/_models/read-history-event'; +import {ServerStatistics} from '../statistics/_models/server-statistics'; +import {StatCount} from '../statistics/_models/stat-count'; +import {PublicationStatus} from '../_models/metadata/publication-status'; +import {MangaFormat} from '../_models/manga-format'; +import {TextResonse} from '../_types/text-response'; import {TranslocoService} from "@jsverse/transloco"; -import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown"; import {throttleTime} from "rxjs/operators"; import {DEBOUNCE_TIME} from "../shared/_services/download.service"; import {download} from "../shared/_models/download"; @@ -44,11 +43,14 @@ export class StatisticsService { constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { } getUserStatistics(userId: number, libraryIds: Array = []) { - // TODO: Convert to httpParams object - let url = 'stats/user/' + userId + '/read'; - if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(','); + const url = `${this.baseUrl}stats/user/${userId}/read`; - return this.httpClient.get(this.baseUrl + url); + let params = new HttpParams(); + if (libraryIds.length > 0) { + params = params.set('libraryIds', libraryIds.join(',')); + } + + return this.httpClient.get(url, { params }); } getServerStatistics() { @@ -59,7 +61,7 @@ export class StatisticsService { return this.httpClient.get[]>(this.baseUrl + 'stats/server/count/year').pipe( map(spreads => spreads.map(spread => { return {name: spread.value + '', value: spread.count}; - }))); + }))); } getTopYears() { @@ -134,8 +136,4 @@ export class StatisticsService { getDayBreakdown( userId = 0) { return this.httpClient.get>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId); } - - getKavitaPlusMetadataBreakdown() { - return this.httpClient.get(this.baseUrl + 'stats/kavitaplus-metadata-breakdown'); - } } diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 9390f25fb..3e186f8ac 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -1,5 +1,5 @@ import {DOCUMENT} from '@angular/common'; -import {HttpClient} from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { DestroyRef, inject, @@ -26,6 +26,7 @@ import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event" import {NavigationEnd, Router} from "@angular/router"; import {ColorscapeService} from "./colorscape.service"; import {ColorScape} from "../_models/theme/colorscape"; +import {debounceTime} from "rxjs/operators"; @Injectable({ providedIn: 'root' @@ -43,6 +44,9 @@ export class ThemeService { private themesSource = new ReplaySubject(1); public themes$ = this.themesSource.asObservable(); + + private darkModeSource = new ReplaySubject(1); + public isDarkMode$ = this.darkModeSource.asObservable(); /** * Maintain a cache of themes. SignalR will inform us if we need to refresh cache @@ -58,12 +62,6 @@ export class ThemeService { private router: Router) { this.renderer = rendererFactory.createRenderer(null, null); - this.router.events.pipe( - filter(event => event instanceof NavigationEnd) - ).subscribe(() => { - this.setColorScape(''); - }); - messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => { if (message.event === EVENTS.NotificationProgress) { @@ -242,9 +240,11 @@ export class ThemeService { } this.currentThemeSource.next(theme); + this.darkModeSource.next(this.isDarkTheme()); }); } else { this.currentThemeSource.next(theme); + this.darkModeSource.next(this.isDarkTheme()); } } else { // Only time themes isn't already loaded is on first load diff --git a/UI/Web/src/app/_services/toggle.service.ts b/UI/Web/src/app/_services/toggle.service.ts index 8b335394a..0ad9813e3 100644 --- a/UI/Web/src/app/_services/toggle.service.ts +++ b/UI/Web/src/app/_services/toggle.service.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@angular/core'; -import { NavigationStart, Router } from '@angular/router'; -import { filter, ReplaySubject, take } from 'rxjs'; +import {Injectable} from '@angular/core'; +import {NavigationStart, Router} from '@angular/router'; +import {filter, ReplaySubject, take} from 'rxjs'; @Injectable({ providedIn: 'root' @@ -29,7 +29,7 @@ export class ToggleService { this.toggleState = !state; this.toggleStateSource.next(this.toggleState); }); - + } set(state: boolean) { diff --git a/UI/Web/src/app/_services/version.service.ts b/UI/Web/src/app/_services/version.service.ts new file mode 100644 index 000000000..ed9d8bac6 --- /dev/null +++ b/UI/Web/src/app/_services/version.service.ts @@ -0,0 +1,250 @@ +import {inject, Injectable, OnDestroy} from '@angular/core'; +import {interval, Subscription, switchMap} from 'rxjs'; +import {ServerService} from "./server.service"; +import {AccountService} from "./account.service"; +import {filter, take} from "rxjs/operators"; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; +import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component"; +import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component"; +import {Router} from "@angular/router"; + +@Injectable({ + providedIn: 'root' +}) +export class VersionService implements OnDestroy{ + + private readonly serverService = inject(ServerService); + private readonly accountService = inject(AccountService); + private readonly modalService = inject(NgbModal); + private readonly router = inject(Router); + + public static readonly SERVER_VERSION_KEY = 'kavita--version'; + public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown'; + public static readonly NEW_UPDATE_KEY = 'kavita--new-update-last-shown'; + public static readonly OUT_OF_BAND_KEY = 'kavita--out-of-band-last-shown'; + + // Notification intervals + private readonly CLIENT_REFRESH_INTERVAL = 0; // Show immediately (once) + private readonly NEW_UPDATE_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds + private readonly OUT_OF_BAND_INTERVAL = 30 * 24 * 60 * 60 * 1000; // 1 month in milliseconds + + // Check intervals + private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes + private readonly OUT_OF_DATE_CHECK_INTERVAL = 2 * 60 * 60 * 1000; // 2 hours + private readonly OUT_Of_BAND_AMOUNT = 3; // How many releases before we show "You're X releases out of date" + + // Routes where version update modals should not be shown + private readonly EXCLUDED_ROUTES = [ + '/manga/', + '/book/', + '/pdf/', + '/reader/' + ]; + + + private versionCheckSubscription?: Subscription; + private outOfDateCheckSubscription?: Subscription; + private modalOpen = false; + + constructor() { + this.startInitialVersionCheck(); + this.startVersionCheck(); + this.startOutOfDateCheck(); + } + + ngOnDestroy() { + this.versionCheckSubscription?.unsubscribe(); + this.outOfDateCheckSubscription?.unsubscribe(); + } + + /** + * Initial version check to ensure localStorage is populated on first load + */ + private startInitialVersionCheck(): void { + this.accountService.currentUser$ + .pipe( + filter(user => !!user), + take(1), + switchMap(user => this.serverService.getVersion(user!.apiKey)) + ) + .subscribe(serverVersion => { + const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); + + // Always update localStorage on first load + localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); + + console.log('Initial version check - Server version:', serverVersion, 'Cached version:', cachedVersion); + }); + } + + /** + * Periodic check for server version to detect client refreshes and new updates + */ + private startVersionCheck(): void { + console.log('Starting version checker'); + this.versionCheckSubscription = interval(this.VERSION_CHECK_INTERVAL) + .pipe( + switchMap(() => this.accountService.currentUser$), + filter(user => !!user && !this.modalOpen), + switchMap(user => this.serverService.getVersion(user!.apiKey)), + filter(update => !!update), + ).subscribe(version => this.handleVersionUpdate(version)); + } + + /** + * Checks if the server is out of date compared to the latest release + */ + private startOutOfDateCheck() { + console.log('Starting out-of-date checker'); + this.outOfDateCheckSubscription = interval(this.OUT_OF_DATE_CHECK_INTERVAL) + .pipe( + switchMap(() => this.accountService.currentUser$), + filter(u => u !== undefined && this.accountService.hasAdminRole(u) && !this.modalOpen), + switchMap(_ => this.serverService.checkHowOutOfDate(true)), + filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > this.OUT_Of_BAND_AMOUNT), + ) + .subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate)); + } + + /** + * Checks if the current route is in the excluded routes list + */ + private isExcludedRoute(): boolean { + const currentUrl = this.router.url; + return this.EXCLUDED_ROUTES.some(route => currentUrl.includes(route)); + } + + /** + * Handles the version check response to determine if client refresh or new update notification is needed + */ + private handleVersionUpdate(serverVersion: string) { + if (this.modalOpen) return; + + // Validate if we are on a reader route and if so, suppress + if (this.isExcludedRoute()) { + console.log('Version update blocked due to user reading'); + return; + } + + const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); + console.log('Server version:', serverVersion, 'Cached version:', cachedVersion); + + const isNewServerVersion = cachedVersion !== null && cachedVersion !== serverVersion; + + // Case 1: Client Refresh needed (server has updated since last client load) + if (isNewServerVersion) { + this.showClientRefreshNotification(serverVersion); + } + // Case 2: Check for new updates (for server admin) + else { + this.checkForNewUpdates(); + } + + // Always update the cached version + localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); + } + + /** + * Shows a notification that client refresh is needed due to server update + */ + private showClientRefreshNotification(newVersion: string): void { + this.pauseChecks(); + + // Client refresh notifications should always show (once) + this.modalOpen = true; + + this.serverService.getChangelog(1).subscribe(changelog => { + const ref = this.modalService.open(NewUpdateModalComponent, { + size: 'lg', + keyboard: false, + backdrop: 'static' // Prevent closing by clicking outside + }); + + ref.componentInstance.version = newVersion; + ref.componentInstance.update = changelog[0]; + ref.componentInstance.requiresRefresh = true; + + // Update the last shown timestamp + localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + }); + } + + /** + * Checks for new server updates and shows notification if appropriate + */ + private checkForNewUpdates(): void { + this.accountService.currentUser$ + .pipe( + take(1), + filter(user => user !== undefined && this.accountService.hasAdminRole(user)), + switchMap(_ => this.serverService.checkHowOutOfDate()), + filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0 && versionsOutOfDate <= this.OUT_Of_BAND_AMOUNT) + ) + .subscribe(versionsOutOfDate => { + const lastShown = Number(localStorage.getItem(VersionService.NEW_UPDATE_KEY) || '0'); + const currentTime = Date.now(); + + // Show notification if it hasn't been shown in the last week + if (currentTime - lastShown >= this.NEW_UPDATE_INTERVAL) { + this.pauseChecks(); + this.modalOpen = true; + + this.serverService.getChangelog(1).subscribe(changelog => { + const ref = this.modalService.open(NewUpdateModalComponent, { size: 'lg' }); + ref.componentInstance.versionsOutOfDate = versionsOutOfDate; + ref.componentInstance.update = changelog[0]; + ref.componentInstance.requiresRefresh = false; + + // Update the last shown timestamp + localStorage.setItem(VersionService.NEW_UPDATE_KEY, currentTime.toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + }); + } + }); + } + + /** + * Handles the notification for servers that are significantly out of date + */ + private handleOutOfDateNotification(versionsOutOfDate: number): void { + const lastShown = Number(localStorage.getItem(VersionService.OUT_OF_BAND_KEY) || '0'); + const currentTime = Date.now(); + + // Show notification if it hasn't been shown in the last month + if (currentTime - lastShown >= this.OUT_OF_BAND_INTERVAL) { + this.pauseChecks(); + this.modalOpen = true; + + const ref = this.modalService.open(OutOfDateModalComponent, { size: 'xl', fullscreen: 'md' }); + ref.componentInstance.versionsOutOfDate = versionsOutOfDate; + + // Update the last shown timestamp + localStorage.setItem(VersionService.OUT_OF_BAND_KEY, currentTime.toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + } + } + + /** + * Pauses all version checks while modals are open + */ + private pauseChecks(): void { + this.versionCheckSubscription?.unsubscribe(); + this.outOfDateCheckSubscription?.unsubscribe(); + } + + /** + * Resumes all checks when modals are closed + */ + private onModalClosed(): void { + this.modalOpen = false; + this.startVersionCheck(); + this.startOutOfDateCheck(); + } +} diff --git a/UI/Web/src/app/_services/volume.service.ts b/UI/Web/src/app/_services/volume.service.ts index c72d45aaa..8c9f9e17e 100644 --- a/UI/Web/src/app/_services/volume.service.ts +++ b/UI/Web/src/app/_services/volume.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import {environment} from "../../environments/environment"; -import {HttpClient} from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; import {Volume} from "../_models/volume"; import {TextResonse} from "../_types/text-response"; @@ -21,7 +21,12 @@ export class VolumeService { return this.httpClient.delete(this.baseUrl + 'volume?volumeId=' + volumeId); } + deleteMultipleVolumes(volumeIds: number[]) { + return this.httpClient.post(this.baseUrl + "volume/multiple", volumeIds) + } + updateVolume(volume: any) { return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse); } + } diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html index 600e46637..7573c554a 100644 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html @@ -1,22 +1,24 @@