diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index ec4a37c81..efe321347 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -10,7 +10,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/BasicTest.cs b/API.Tests/BasicTest.cs new file mode 100644 index 000000000..c40b3706d --- /dev/null +++ b/API.Tests/BasicTest.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +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; +using API.Services; +using AutoMapper; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace API.Tests; + +public abstract class BasicTest +{ + private 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 TempDirectory = "C:/kavita/config/temp/"; + + protected BasicTest() + { + var contextOptions = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + + _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); + } + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + 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; + + 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"; + + _context.ServerSetting.Update(setting); + + _context.Library.Add(new Library() + { + Name = "Manga", + Folders = new List() + { + new FolderPath() + { + Path = "C:/data/" + } + } + }); + return await _context.SaveChangesAsync() > 0; + } + + protected async Task ResetDB() + { + _context.Series.RemoveRange(_context.Series.ToList()); + _context.Users.RemoveRange(_context.Users.ToList()); + _context.AppUserBookmark.RemoveRange(_context.AppUserBookmark.ToList()); + + await _context.SaveChangesAsync(); + } + + protected 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(LogDirectory); + fileSystem.AddDirectory(TempDirectory); + fileSystem.AddDirectory("C:/data/"); + + return fileSystem; + } +} diff --git a/API.Tests/Helpers/CacheHelperTests.cs b/API.Tests/Helpers/CacheHelperTests.cs index 723742bc6..d78ed1601 100644 --- a/API.Tests/Helpers/CacheHelperTests.cs +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -165,7 +165,7 @@ public class CacheHelperTests FilePath = TestCoverArchive, LastModified = filesystemFile.LastWriteTime.DateTime }; - Assert.True(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + Assert.True(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); } [Fact] @@ -195,7 +195,7 @@ public class CacheHelperTests FilePath = TestCoverArchive, LastModified = filesystemFile.LastWriteTime.DateTime }; - Assert.True(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + Assert.True(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); } [Fact] @@ -225,15 +225,16 @@ public class CacheHelperTests FilePath = TestCoverArchive, LastModified = filesystemFile.LastWriteTime.DateTime }; - Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, true, file)); + Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, true, file)); } [Fact] - public void HasFileNotChangedSinceCreationOrLastScan_ModifiedSinceLastScan() + public void IsFileUnmodifiedSinceCreationOrLastScan_ModifiedSinceLastScan() { var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime = DateTimeOffset.Now, + CreationTime = DateTimeOffset.Now }; var fileSystem = new MockFileSystem(new Dictionary { @@ -246,8 +247,8 @@ public class CacheHelperTests var chapter = new Chapter() { - Created = filesystemFile.LastWriteTime.DateTime.Subtract(TimeSpan.FromMinutes(10)), - LastModified = filesystemFile.LastWriteTime.DateTime.Subtract(TimeSpan.FromMinutes(10)) + Created = DateTime.Now.Subtract(TimeSpan.FromMinutes(10)), + LastModified = DateTime.Now.Subtract(TimeSpan.FromMinutes(10)) }; var file = new MangaFile() @@ -255,7 +256,7 @@ public class CacheHelperTests FilePath = Path.Join(TestCoverImageDirectory, TestCoverArchive), LastModified = filesystemFile.LastWriteTime.DateTime }; - Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); } [Fact] @@ -276,8 +277,8 @@ public class CacheHelperTests var chapter = new Chapter() { - Created = filesystemFile.LastWriteTime.DateTime.Subtract(TimeSpan.FromMinutes(10)), - LastModified = filesystemFile.LastWriteTime.DateTime + Created = DateTime.Now.Subtract(TimeSpan.FromMinutes(10)), + LastModified = DateTime.Now }; var file = new MangaFile() @@ -285,7 +286,7 @@ public class CacheHelperTests FilePath = Path.Join(TestCoverImageDirectory, TestCoverArchive), LastModified = filesystemFile.LastWriteTime.DateTime }; - Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); } } diff --git a/API.Tests/Services/DeviceServiceTests.cs b/API.Tests/Services/DeviceServiceTests.cs new file mode 100644 index 000000000..a03f1e715 --- /dev/null +++ b/API.Tests/Services/DeviceServiceTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Device; +using API.Entities; +using API.Entities.Enums.Device; +using API.Services; +using API.Services.Tasks; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class DeviceServiceTests : BasicTest +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IDeviceService _deviceService; + + public DeviceServiceTests() : base() + { + _deviceService = new DeviceService(_unitOfWork, _logger, Substitute.For()); + } + + protected void ResetDB() + { + _context.Users.RemoveRange(_context.Users.ToList()); + } + + + + [Fact] + public async Task CreateDevice_Succeeds() + { + + var user = new AppUser() + { + UserName = "majora2007", + Devices = new List() + }; + + _context.Users.Add(user); + await _unitOfWork.CommitAsync(); + + var device = await _deviceService.Create(new CreateDeviceDto() + { + EmailAddress = "fake@kindle.com", + Name = "Test Kindle", + Platform = DevicePlatform.Kindle + }, user); + + Assert.NotNull(device); + + } + + [Fact] + public async Task CreateDevice_ThrowsErrorWhenEmailDoesntMatchRules() + { + + var user = new AppUser() + { + UserName = "majora2007", + Devices = new List() + }; + + _context.Users.Add(user); + await _unitOfWork.CommitAsync(); + + var device = await _deviceService.Create(new CreateDeviceDto() + { + EmailAddress = "fake@gmail.com", + Name = "Test Kindle", + Platform = DevicePlatform.Kindle + }, user); + + Assert.NotNull(device); + + } +} diff --git a/API/Controllers/DeviceController.cs b/API/Controllers/DeviceController.cs new file mode 100644 index 000000000..ff96ea2b2 --- /dev/null +++ b/API/Controllers/DeviceController.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Device; +using API.Extensions; +using API.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +/// +/// Responsible interacting and creating Devices +/// +public class DeviceController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IDeviceService _deviceService; + private readonly IEmailService _emailService; + + public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, IEmailService emailService) + { + _unitOfWork = unitOfWork; + _deviceService = deviceService; + _emailService = emailService; + } + + + [HttpPost("create")] + public async Task CreateOrUpdateDevice(CreateDeviceDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices); + var device = await _deviceService.Create(dto, user); + + if (device == null) return BadRequest("There was an error when creating the device"); + + return Ok(); + } + + [HttpPost("update")] + public async Task UpdateDevice(UpdateDeviceDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices); + var device = await _deviceService.Update(dto, user); + + if (device == null) return BadRequest("There was an error when updating the device"); + + return Ok(); + } + + /// + /// Deletes the device from the user + /// + /// + /// + [HttpDelete] + public async Task DeleteDevice(int deviceId) + { + if (deviceId <= 0) return BadRequest("Not a valid deviceId"); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices); + if (await _deviceService.Delete(user, deviceId)) return Ok(); + + return BadRequest("Could not delete device"); + } + + [HttpGet] + public async Task>> GetDevices() + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(userId)); + } + + [HttpPost("send-to")] + public async Task SendToDevice(SendToDeviceDto dto) + { + if (dto.ChapterId < 0) return BadRequest("ChapterId must be greater than 0"); + if (dto.DeviceId < 0) return BadRequest("DeviceId must be greater than 0"); + + if (await _emailService.IsDefaultEmailService()) + return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own."); + + if (await _deviceService.SendTo(dto.ChapterId, dto.DeviceId)) return Ok(); + + return BadRequest("There was an error sending the file to the device"); + } + + +} + + diff --git a/API/Controllers/FallbackController.cs b/API/Controllers/FallbackController.cs index a765269b8..2f5d7fceb 100644 --- a/API/Controllers/FallbackController.cs +++ b/API/Controllers/FallbackController.cs @@ -1,7 +1,9 @@ -using System.IO; +using System; +using System.IO; using API.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace API.Controllers; diff --git a/API/DTOs/Device/CreateDeviceDto.cs b/API/DTOs/Device/CreateDeviceDto.cs new file mode 100644 index 000000000..bdcdde194 --- /dev/null +++ b/API/DTOs/Device/CreateDeviceDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.Runtime.InteropServices; +using API.Entities.Enums.Device; + +namespace API.DTOs.Device; + +public class CreateDeviceDto +{ + [Required] + public string Name { get; set; } + /// + /// Platform of the device. If not know, defaults to "Custom" + /// + [Required] + public DevicePlatform Platform { get; set; } + [Required] + public string EmailAddress { get; set; } + + +} diff --git a/API/DTOs/Device/DeviceDto.cs b/API/DTOs/Device/DeviceDto.cs new file mode 100644 index 000000000..e5344f31e --- /dev/null +++ b/API/DTOs/Device/DeviceDto.cs @@ -0,0 +1,33 @@ +using System; +using API.Entities.Enums.Device; + +namespace API.DTOs.Device; + +/// +/// A Device is an entity that can receive data from Kavita (kindle) +/// +public class DeviceDto +{ + /// + /// The device Id + /// + public int Id { get; set; } + /// + /// A name given to this device + /// + /// If this device is web, this will be the browser name + /// Pixel 3a, John's Kindle + public string Name { get; set; } + /// + /// An email address associated with the device (ie Kindle). Will be used with Send to functionality + /// + public string EmailAddress { get; set; } + /// + /// Platform (ie) Windows 10 + /// + public DevicePlatform Platform { get; set; } + /// + /// Last time this device was used to send a file + /// + public DateTime LastUsed { get; set; } +} diff --git a/API/DTOs/Device/SendToDeviceDto.cs b/API/DTOs/Device/SendToDeviceDto.cs new file mode 100644 index 000000000..20d8cf311 --- /dev/null +++ b/API/DTOs/Device/SendToDeviceDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Device; + +public class SendToDeviceDto +{ + public int DeviceId { get; set; } + public int ChapterId { get; set; } +} diff --git a/API/DTOs/Device/UpdateDeviceDto.cs b/API/DTOs/Device/UpdateDeviceDto.cs new file mode 100644 index 000000000..201adcb5d --- /dev/null +++ b/API/DTOs/Device/UpdateDeviceDto.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using API.Entities.Enums.Device; + +namespace API.DTOs.Device; + +public class UpdateDeviceDto +{ + [Required] + public int Id { get; set; } + [Required] + public string Name { get; set; } + /// + /// Platform of the device. If not know, defaults to "Custom" + /// + [Required] + public DevicePlatform Platform { get; set; } + [Required] + public string EmailAddress { get; set; } +} diff --git a/API/DTOs/Email/SendToDto.cs b/API/DTOs/Email/SendToDto.cs new file mode 100644 index 000000000..254f7fd09 --- /dev/null +++ b/API/DTOs/Email/SendToDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace API.DTOs.Email; + +public class SendToDto +{ + public string DestinationEmail { get; set; } + public IEnumerable FilePaths { get; set; } +} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 7b1ce8d36..c00289227 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -44,6 +44,7 @@ public sealed class DataContext : IdentityDbContext SiteTheme { get; set; } public DbSet SeriesRelation { get; set; } public DbSet FolderPath { get; set; } + public DbSet Device { get; set; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index 8151030dd..891c10843 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -162,7 +162,15 @@ public static class DbFactory FilePath = filePath, Format = format, Pages = pages, - LastModified = File.GetLastWriteTime(filePath) // NOTE: Changed this from DateTime.Now + LastModified = File.GetLastWriteTime(filePath) + }; + } + + public static Device Device(string name) + { + return new Device() + { + Name = name, }; } diff --git a/API/Data/Migrations/20220921023455_DeviceSupport.Designer.cs b/API/Data/Migrations/20220921023455_DeviceSupport.Designer.cs new file mode 100644 index 000000000..dbf4a0af6 --- /dev/null +++ b/API/Data/Migrations/20220921023455_DeviceSupport.Designer.cs @@ -0,0 +1,1658 @@ +// +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("20220921023455_DeviceSupport")] + partial class DeviceSupport + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.9"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .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("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .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.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("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + 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("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + 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.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .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("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + 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("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .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("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("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + 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("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + 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.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + 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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + 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("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("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + 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.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.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", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .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.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.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .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.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.ClientCascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .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.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + 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("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("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .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("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("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .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("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + 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/20220921023455_DeviceSupport.cs b/API/Data/Migrations/20220921023455_DeviceSupport.cs new file mode 100644 index 000000000..7723daa41 --- /dev/null +++ b/API/Data/Migrations/20220921023455_DeviceSupport.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class DeviceSupport : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_SeriesRelation_Series_TargetSeriesId", + table: "SeriesRelation"); + + migrationBuilder.CreateTable( + name: "Device", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + IpAddress = table.Column(type: "TEXT", nullable: true), + Name = table.Column(type: "TEXT", nullable: true), + EmailAddress = table.Column(type: "TEXT", nullable: true), + Platform = table.Column(type: "INTEGER", nullable: false), + AppUserId = table.Column(type: "INTEGER", nullable: false), + LastUsed = table.Column(type: "TEXT", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Device", x => x.Id); + table.ForeignKey( + name: "FK_Device_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Device_AppUserId", + table: "Device", + column: "AppUserId"); + + migrationBuilder.AddForeignKey( + name: "FK_SeriesRelation_Series_TargetSeriesId", + table: "SeriesRelation", + column: "TargetSeriesId", + principalTable: "Series", + principalColumn: "Id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_SeriesRelation_Series_TargetSeriesId", + table: "SeriesRelation"); + + migrationBuilder.DropTable( + name: "Device"); + + migrationBuilder.AddForeignKey( + name: "FK_SeriesRelation_Series_TargetSeriesId", + table: "SeriesRelation", + column: "TargetSeriesId", + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index d65cc4adb..525e07bdc 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "6.0.7"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.9"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -442,6 +442,43 @@ namespace API.Data.Migrations 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("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .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") @@ -1262,6 +1299,17 @@ namespace API.Data.Migrations 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.FolderPath", b => { b.HasOne("API.Entities.Library", "Library") @@ -1306,7 +1354,7 @@ namespace API.Data.Migrations b.HasOne("API.Entities.Series", "TargetSeries") .WithMany("RelationOf") .HasForeignKey("TargetSeriesId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.ClientCascade) .IsRequired(); b.Navigation("Series"); @@ -1551,6 +1599,8 @@ namespace API.Data.Migrations { b.Navigation("Bookmarks"); + b.Navigation("Devices"); + b.Navigation("Progresses"); b.Navigation("Ratings"); diff --git a/API/Data/Repositories/DeviceRepository.cs b/API/Data/Repositories/DeviceRepository.cs new file mode 100644 index 000000000..b6f139bc1 --- /dev/null +++ b/API/Data/Repositories/DeviceRepository.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Device; +using API.Entities; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface IDeviceRepository +{ + void Update(Device device); + Task> GetDevicesForUserAsync(int userId); + Task GetDeviceById(int deviceId); +} + +public class DeviceRepository : IDeviceRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public DeviceRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Update(Device device) + { + _context.Entry(device).State = EntityState.Modified; + } + + public async Task> GetDevicesForUserAsync(int userId) + { + return await _context.Device + .Where(d => d.AppUserId == userId) + .OrderBy(d => d.LastUsed) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task GetDeviceById(int deviceId) + { + return await _context.Device + .Where(d => d.Id == deviceId) + .SingleOrDefaultAsync(); + } +} diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 7aaa4b040..6dc59fea5 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -27,6 +27,7 @@ public enum AppUserIncludes UserPreferences = 32, WantToRead = 64, ReadingListsWithItems = 128, + Devices = 256, } @@ -194,6 +195,11 @@ public class UserRepository : IUserRepository query = query.Include(u => u.WantToRead); } + if (includeFlags.HasFlag(AppUserIncludes.Devices)) + { + query = query.Include(u => u.Devices); + } + return query; diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 2d2adac42..50aadf421 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -24,6 +24,7 @@ public interface IUnitOfWork ITagRepository TagRepository { get; } ISiteThemeRepository SiteThemeRepository { get; } IMangaFileRepository MangaFileRepository { get; } + IDeviceRepository DeviceRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -60,6 +61,7 @@ public class UnitOfWork : IUnitOfWork public ITagRepository TagRepository => new TagRepository(_context, _mapper); public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper); public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context, _mapper); + public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index da536c96f..cf8ab3304 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -16,6 +16,9 @@ public class AppUser : IdentityUser, IHasConcurrencyToken public ICollection Progresses { get; set; } public ICollection Ratings { get; set; } public AppUserPreferences UserPreferences { get; set; } + /// + /// Bookmarks associated with this User + /// public ICollection Bookmarks { get; set; } /// /// Reading lists associated with this user @@ -26,6 +29,10 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// public ICollection WantToRead { get; set; } /// + /// A list of Devices which allows the user to send files to + /// + public ICollection Devices { get; set; } + /// /// An API Key to interact with external services, like OPDS /// public string ApiKey { get; set; } diff --git a/API/Entities/Device.cs b/API/Entities/Device.cs new file mode 100644 index 000000000..224f96aed --- /dev/null +++ b/API/Entities/Device.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Net; +using API.Entities.Enums.Device; +using API.Entities.Interfaces; + +namespace API.Entities; + +/// +/// A Device is an entity that can receive data from Kavita (kindle) +/// +public class Device : IEntityDate +{ + public int Id { get; set; } + /// + /// Last Seen IP Address of the device + /// + public string IpAddress { get; set; } + /// + /// A name given to this device + /// + /// If this device is web, this will be the browser name + /// Pixel 3a, John's Kindle + public string Name { get; set; } + /// + /// An email address associated with the device (ie Kindle). Will be used with Send to functionality + /// + public string EmailAddress { get; set; } + /// + /// Platform (ie) Windows 10 + /// + public DevicePlatform Platform { get; set; } + + + //public ICollection SupportedExtensions { get; set; } // TODO: This requires some sort of information at mangaFile level (unless i repack) + + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } + + + /// + /// Last time this device was used to send a file + /// + public DateTime LastUsed { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } +} diff --git a/API/Entities/Enums/Device/DevicePlatform.cs b/API/Entities/Enums/Device/DevicePlatform.cs new file mode 100644 index 000000000..9b91bddbc --- /dev/null +++ b/API/Entities/Enums/Device/DevicePlatform.cs @@ -0,0 +1,25 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.Device; + +public enum DevicePlatform +{ + [Description("Custom")] + Custom = 0, + /// + /// PocketBook device, email ends in @pbsync.com + /// + [Description("PocketBook")] + PocketBook = 1, + /// + /// Kindle device, email ends in @kindle.com + /// + [Description("Kindle")] + Kindle = 2, + /// + /// Kobo device, + /// + [Description("Kobo")] + Kobo = 3, + +} diff --git a/API/Entities/Metadata/SeriesRelation.cs b/API/Entities/Metadata/SeriesRelation.cs index 46e6c34b9..bb152264a 100644 --- a/API/Entities/Metadata/SeriesRelation.cs +++ b/API/Entities/Metadata/SeriesRelation.cs @@ -8,18 +8,18 @@ namespace API.Entities.Metadata; /// A relation flows between one series and another. /// Series ---kind---> target /// -public class SeriesRelation +public sealed class SeriesRelation { public int Id { get; set; } public RelationKind RelationKind { get; set; } - public virtual Series TargetSeries { get; set; } + public Series TargetSeries { get; set; } /// /// A is Sequel to B. In this example, TargetSeries is A. B will hold the foreign key. /// public int TargetSeriesId { get; set; } // Relationships - public virtual Series Series { get; set; } + public Series Series { get; set; } public int SeriesId { get; set; } } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index c696e4858..bece2e76d 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -49,6 +49,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index e69db721d..2a0295a7e 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -2,6 +2,7 @@ using System.Linq; using API.DTOs; using API.DTOs.CollectionTags; +using API.DTOs.Device; using API.DTOs.Metadata; using API.DTOs.Reader; using API.DTOs.ReadingLists; @@ -97,11 +98,6 @@ public class AutoMapperProfiles : Profile opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); - // CreateMap() - // .ForMember(dest => dest.Adaptations, - // opt => - // opt.MapFrom(src => src.Where(p => p.Role == PersonRole.Writer))) - CreateMap(); CreateMap(); CreateMap() @@ -144,5 +140,7 @@ public class AutoMapperProfiles : Profile CreateMap, ServerSettingDto>() .ConvertUsing(); + CreateMap(); + } } diff --git a/API/Helpers/CacheHelper.cs b/API/Helpers/CacheHelper.cs index 3f4382d57..06a2ba764 100644 --- a/API/Helpers/CacheHelper.cs +++ b/API/Helpers/CacheHelper.cs @@ -13,7 +13,7 @@ public interface ICacheHelper bool CoverImageExists(string path); - bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile); + bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile); bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile); } @@ -49,13 +49,13 @@ public class CacheHelper : ICacheHelper } /// - /// Has the file been modified since last scan or is user forcing an update + /// Has the file been not been modified since last scan or is user forcing an update /// /// /// /// /// - public bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile) + public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile) { return firstFile != null && (!forceUpdate && diff --git a/API/Program.cs b/API/Program.cs index 4afecbced..6e1d3f365 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -117,7 +117,7 @@ public class Program Log.Fatal(ex, "Host terminated unexpectedly"); } finally { - Log.CloseAndFlush(); + await Log.CloseAndFlushAsync(); } } diff --git a/API/Services/DeviceService.cs b/API/Services/DeviceService.cs new file mode 100644 index 000000000..4d5973058 --- /dev/null +++ b/API/Services/DeviceService.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Device; +using API.DTOs.Email; +using API.Entities; +using API.Entities.Enums; +using Kavita.Common; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IDeviceService +{ + Task Create(CreateDeviceDto dto, AppUser userWithDevices); + Task Update(UpdateDeviceDto dto, AppUser userWithDevices); + Task Delete(AppUser userWithDevices, int deviceId); + Task SendTo(int chapterId, int deviceId); +} + +public class DeviceService : IDeviceService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IEmailService _emailService; + + public DeviceService(IUnitOfWork unitOfWork, ILogger logger, IEmailService emailService) + { + _unitOfWork = unitOfWork; + _logger = logger; + _emailService = emailService; + } + #nullable enable + public async Task Create(CreateDeviceDto dto, AppUser userWithDevices) + { + try + { + userWithDevices.Devices ??= new List(); + var existingDevice = userWithDevices.Devices.SingleOrDefault(d => d.Name.Equals(dto.Name)); + if (existingDevice != null) throw new KavitaException("A device with this name already exists"); + + existingDevice = DbFactory.Device(dto.Name); + existingDevice.Platform = dto.Platform; + existingDevice.EmailAddress = dto.EmailAddress; + + + userWithDevices.Devices.Add(existingDevice); + _unitOfWork.UserRepository.Update(userWithDevices); + + if (!_unitOfWork.HasChanges()) return existingDevice; + if (await _unitOfWork.CommitAsync()) return existingDevice; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error when creating your device"); + await _unitOfWork.RollbackAsync(); + } + + return null; + } + + public async Task Update(UpdateDeviceDto dto, AppUser userWithDevices) + { + try + { + var existingDevice = userWithDevices.Devices.SingleOrDefault(d => d.Id == dto.Id); + if (existingDevice == null) throw new KavitaException("This device doesn't exist yet. Please create first"); + + existingDevice.Name = dto.Name; + existingDevice.Platform = dto.Platform; + existingDevice.EmailAddress = dto.EmailAddress; + + if (!_unitOfWork.HasChanges()) return existingDevice; + if (await _unitOfWork.CommitAsync()) return existingDevice; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error when updating your device"); + await _unitOfWork.RollbackAsync(); + } + + return null; + } + #nullable disable + + public async Task Delete(AppUser userWithDevices, int deviceId) + { + try + { + userWithDevices.Devices = userWithDevices.Devices.Where(d => d.Id != deviceId).ToList(); + _unitOfWork.UserRepository.Update(userWithDevices); + if (!_unitOfWork.HasChanges()) return true; + if (await _unitOfWork.CommitAsync()) return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue with deleting the device, {DeviceId} for user {UserName}", deviceId, userWithDevices.UserName); + } + + return false; + } + + public async Task SendTo(int chapterId, int deviceId) + { + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); + if (files.Any(f => f.Format is not (MangaFormat.Epub or MangaFormat.Pdf))) + throw new KavitaException("Cannot Send non Epub or Pdf to devices as not supported"); + + var device = await _unitOfWork.DeviceRepository.GetDeviceById(deviceId); + if (device == null) throw new KavitaException("Device doesn't exist"); + device.LastUsed = DateTime.Now; + _unitOfWork.DeviceRepository.Update(device); + await _unitOfWork.CommitAsync(); + var success = await _emailService.SendFilesToEmail(new SendToDto() + { + DestinationEmail = device.EmailAddress, + FilePaths = files.Select(m => m.FilePath) + }); + return success; + } +} diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 819a0c77a..535ab49cc 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading.Tasks; using API.Data; using API.DTOs.Email; @@ -11,6 +14,7 @@ using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; namespace API.Services; @@ -20,23 +24,27 @@ public interface IEmailService Task CheckIfAccessible(string host); Task SendMigrationEmail(EmailMigrationDto data); Task SendPasswordResetEmail(PasswordResetEmailDto data); + Task SendFilesToEmail(SendToDto data); Task TestConnectivity(string emailUrl); + Task IsDefaultEmailService(); } public class EmailService : IEmailService { private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; + private readonly IDownloadService _downloadService; /// /// This is used to initially set or reset the ServerSettingKey. Do not access from the code, access via UnitOfWork /// public const string DefaultApiUrl = "https://email.kavitareader.com"; - public EmailService(ILogger logger, IUnitOfWork unitOfWork) + public EmailService(ILogger logger, IUnitOfWork unitOfWork, IDownloadService downloadService) { _logger = logger; _unitOfWork = unitOfWork; + _downloadService = downloadService; FlurlHttp.ConfigureClient(DefaultApiUrl, cli => cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); @@ -58,7 +66,7 @@ public class EmailService : IEmailService result.Successful = false; result.ErrorMessage = "This is a local IP address"; } - result.Successful = await SendEmailWithGet(emailUrl + "/api/email/test"); + result.Successful = await SendEmailWithGet(emailUrl + "/api/test"); } catch (KavitaException ex) { @@ -69,10 +77,16 @@ public class EmailService : IEmailService return result; } + public async Task IsDefaultEmailService() + { + return (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value + .Equals(DefaultApiUrl); + } + public async Task SendConfirmationEmail(ConfirmationEmailDto data) { var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; - var success = await SendEmailWithPost(emailLink + "/api/email/confirm", data); + var success = await SendEmailWithPost(emailLink + "/api/invite/confirm", data); if (!success) { _logger.LogError("There was a critical error sending Confirmation email"); @@ -85,7 +99,7 @@ public class EmailService : IEmailService try { if (IsLocalIpAddress(host)) return false; - return await SendEmailWithGet(DefaultApiUrl + "/api/email/reachable?host=" + host); + return await SendEmailWithGet(DefaultApiUrl + "/api/reachable?host=" + host); } catch (Exception) { @@ -96,13 +110,20 @@ public class EmailService : IEmailService public async Task SendMigrationEmail(EmailMigrationDto data) { var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; - return await SendEmailWithPost(emailLink + "/api/email/email-migration", data); + return await SendEmailWithPost(emailLink + "/api/invite/email-migration", data); } public async Task SendPasswordResetEmail(PasswordResetEmailDto data) { var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; - return await SendEmailWithPost(emailLink + "/api/email/email-password-reset", data); + return await SendEmailWithPost(emailLink + "/api/invite/email-password-reset", data); + } + + public async Task SendFilesToEmail(SendToDto data) + { + if (await IsDefaultEmailService()) return false; + var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value; + return await SendEmailWithFiles(emailLink + "/api/sendto", data.FilePaths, data.DestinationEmail); } private static async Task SendEmailWithGet(string url, int timeoutSecs = 30) @@ -156,6 +177,41 @@ public class EmailService : IEmailService return true; } + + private async Task SendEmailWithFiles(string url, IEnumerable filePaths, string destEmail, int timeoutSecs = 30) + { + try + { + var response = await (url) + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithTimeout(TimeSpan.FromSeconds(timeoutSecs)) + .PostMultipartAsync(mp => + { + mp.AddString("email", destEmail); + var index = 1; + foreach (var filepath in filePaths) + { + mp.AddFile("file" + index, filepath, _downloadService.GetContentTypeFromFile(filepath)); + index++; + } + } + ); + + if (response.StatusCode != StatusCodes.Status200OK) + { + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when sending Email for SendTo"); + return false; + } + return true; + } + private static bool IsLocalIpAddress(string url) { var host = url.Split(':')[0]; diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 0dd980a59..8eed9ebe9 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -89,7 +89,7 @@ public class MetadataService : IMetadataService private void UpdateChapterLastModified(Chapter chapter, bool forceUpdate) { var firstFile = chapter.Files.MinBy(x => x.Chapter); - if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return; + if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return; firstFile.UpdateLastModified(); } diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 25cc4d35e..86f027566 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -40,8 +40,8 @@ public class ReaderService : IReaderService private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IEventHub _eventHub; - private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); - private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default; + private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerZeroFirst.Default; private const float MinWordsPerHour = 10260F; private const float MaxWordsPerHour = 30000F; diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index 24b938153..dd6e3cc34 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -83,6 +83,8 @@ public class LibraryWatcher : ILibraryWatcher watcher.Created += OnCreated; watcher.Deleted += OnDeleted; watcher.Error += OnError; + watcher.Disposed += (sender, args) => + _logger.LogError("[LibraryWatcher] watcher was disposed when it shouldn't have been"); watcher.Filter = "*.*"; watcher.IncludeSubdirectories = true; @@ -108,7 +110,6 @@ public class LibraryWatcher : ILibraryWatcher fileSystemWatcher.Created -= OnCreated; fileSystemWatcher.Deleted -= OnDeleted; fileSystemWatcher.Error -= OnError; - fileSystemWatcher.Dispose(); } FileWatchers.Clear(); WatcherDictionary.Clear(); diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 807ef8f31..2c07f1805 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -458,7 +458,7 @@ public class ProcessSeries : IProcessSeries foreach (var chapter in volume.Chapters) { var firstFile = chapter.Files.MinBy(x => x.Chapter); - if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, firstFile)) continue; + if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) continue; try { var firstChapterInfo = infos.SingleOrDefault(i => i.FullFilePath.Equals(firstFile.FilePath)); @@ -583,7 +583,7 @@ public class ProcessSeries : IProcessSeries { var firstFile = chapter.Files.MinBy(x => x.Chapter); if (firstFile == null || - _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, firstFile)) return; + _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) return; var comicInfo = info; if (info == null) diff --git a/API/Startup.cs b/API/Startup.cs index 1291a702c..2da14d07f 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -53,6 +53,7 @@ public class Startup public void ConfigureServices(IServiceCollection services) { services.AddApplicationServices(_config, _env); + services.AddControllers(options => { options.CacheProfiles.Add("Images", diff --git a/Kavita.Common/EnvironmentInfo/BuildInfo.cs b/Kavita.Common/EnvironmentInfo/BuildInfo.cs index 116c07866..84f5f387c 100644 --- a/Kavita.Common/EnvironmentInfo/BuildInfo.cs +++ b/Kavita.Common/EnvironmentInfo/BuildInfo.cs @@ -1,33 +1,11 @@ using System; -using System.Linq; using System.Reflection; namespace Kavita.Common.EnvironmentInfo; public static class BuildInfo { - static BuildInfo() - { - var assembly = Assembly.GetExecutingAssembly(); - - Version = assembly.GetName().Version; - - var attributes = assembly.GetCustomAttributes(true); - - Branch = "unknown"; - - var config = attributes.OfType().FirstOrDefault(); - if (config != null) - { - Branch = config.Configuration; // NOTE: This is not helpful, better to have main/develop branch - } - - Release = $"{Version}-{Branch}"; - } - + public static readonly Version Version = Assembly.GetExecutingAssembly().GetName().Version; public static string AppName { get; } = "Kavita"; - public static Version Version { get; } - public static string Branch { get; } - public static string Release { get; } } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index f3660b016..bfaf7d0e0 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/src/app/_models/device/device-platform.ts b/UI/Web/src/app/_models/device/device-platform.ts new file mode 100644 index 000000000..3c88a2160 --- /dev/null +++ b/UI/Web/src/app/_models/device/device-platform.ts @@ -0,0 +1,8 @@ +export enum DevicePlatform { + Custom = 0, + PocketBook = 1, + Kindle = 2, + Kobo = 3 +} + +export const devicePlatforms = [DevicePlatform.Custom, DevicePlatform.Kindle, DevicePlatform.Kobo, DevicePlatform.PocketBook]; \ No newline at end of file diff --git a/UI/Web/src/app/_models/device/device.ts b/UI/Web/src/app/_models/device/device.ts new file mode 100644 index 000000000..435be4937 --- /dev/null +++ b/UI/Web/src/app/_models/device/device.ts @@ -0,0 +1,9 @@ +import { DevicePlatform } from "./device-platform"; + +export interface Device { + id: number; + name: string; + platform: DevicePlatform; + emailAddress: string; + lastUsed: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index ab063082d..600c616ad 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, OnDestroy } from '@angular/core'; -import { Observable, of, ReplaySubject, Subject } from 'rxjs'; +import { of, ReplaySubject, Subject } from 'rxjs'; import { filter, map, switchMap, takeUntil } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Preferences } from '../_models/preferences/preferences'; @@ -10,6 +10,7 @@ import { EVENTS, MessageHubService } from './message-hub.service'; import { ThemeService } from './theme.service'; import { InviteUserResponse } from '../_models/invite-user-response'; import { UserUpdateEvent } from '../_models/events/user-update-event'; +import { DeviceService } from './device.service'; @Injectable({ providedIn: 'root' @@ -66,7 +67,7 @@ export class AccountService implements OnDestroy { return this.httpClient.get(this.baseUrl + 'account/roles'); } - login(model: {username: string, password: string}): Observable { + login(model: {username: string, password: string}) { return this.httpClient.post(this.baseUrl + 'account/login', model).pipe( map((response: User) => { const user = response; diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 2ed6ec6c1..bcc577b31 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -1,15 +1,17 @@ import { Injectable } from '@angular/core'; +import { map, Observable, shareReplay } from 'rxjs'; import { Chapter } from '../_models/chapter'; import { CollectionTag } from '../_models/collection-tag'; +import { Device } from '../_models/device/device'; import { Library } from '../_models/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'; export enum Action { - AddTo = -2, - Others = -1, + Submenu = -1, /** * Mark entity as read */ @@ -78,14 +80,26 @@ export enum Action { * Remove from user's Want to Read List */ RemoveFromWantToReadList = 16, + /** + * Send to a device + */ + SendTo = 17, } export interface ActionItem { title: string; action: Action; - callback: (action: Action, data: T) => void; + callback: (action: ActionItem, data: T) => void; requiresAdmin: boolean; children: Array>; + /** + * Indicates that there exists a separate list will be loaded from an API + */ + dynamicList?: Observable<{title: string, data: any}[]> | undefined; + /** + * 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?: any; } @Injectable({ @@ -109,7 +123,7 @@ export class ActionFactoryService { isAdmin = false; hasDownloadRole = false; - constructor(private accountService: AccountService) { + constructor(private accountService: AccountService, private deviceService: DeviceService) { this.accountService.currentUser$.subscribe((user) => { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); @@ -123,35 +137,35 @@ export class ActionFactoryService { }); } - getLibraryActions(callback: (action: Action, library: Library) => void) { + getLibraryActions(callback: (action: ActionItem, library: Library) => void) { return this.applyCallbackToList(this.libraryActions, callback); } - getSeriesActions(callback: (action: Action, series: Series) => void) { + getSeriesActions(callback: (action: ActionItem, series: Series) => void) { return this.applyCallbackToList(this.seriesActions, callback); } - getVolumeActions(callback: (action: Action, volume: Volume) => void) { + getVolumeActions(callback: (action: ActionItem, volume: Volume) => void) { return this.applyCallbackToList(this.volumeActions, callback); } - getChapterActions(callback: (action: Action, chapter: Chapter) => void) { + getChapterActions(callback: (action: ActionItem, chapter: Chapter) => void) { return this.applyCallbackToList(this.chapterActions, callback); } - getCollectionTagActions(callback: (action: Action, collectionTag: CollectionTag) => void) { + getCollectionTagActions(callback: (action: ActionItem, collectionTag: CollectionTag) => void) { return this.applyCallbackToList(this.collectionTagActions, callback); } - getReadingListActions(callback: (action: Action, readingList: ReadingList) => void) { + getReadingListActions(callback: (action: ActionItem, readingList: ReadingList) => void) { return this.applyCallbackToList(this.readingListActions, callback); } - getBookmarkActions(callback: (action: Action, series: Series) => void) { + getBookmarkActions(callback: (action: ActionItem, series: Series) => void) { return this.applyCallbackToList(this.bookmarkActions, callback); } - dummyCallback(action: Action, data: any) {} + dummyCallback(action: ActionItem, data: any) {} _resetActions() { this.libraryActions = [ @@ -163,7 +177,7 @@ export class ActionFactoryService { children: [], }, { - action: Action.Others, + action: Action.Submenu, title: 'Others', callback: this.dummyCallback, requiresAdmin: true, @@ -212,7 +226,7 @@ export class ActionFactoryService { children: [], }, { - action: Action.AddTo, + action: Action.Submenu, title: 'Add to', callback: this.dummyCallback, requiresAdmin: false, @@ -262,7 +276,7 @@ export class ActionFactoryService { children: [], }, { - action: Action.Others, + action: Action.Submenu, title: 'Others', callback: this.dummyCallback, requiresAdmin: false, @@ -315,7 +329,7 @@ export class ActionFactoryService { children: [], }, { - action: Action.AddTo, + action: Action.Submenu, title: 'Add to', callback: this.dummyCallback, requiresAdmin: false, @@ -368,7 +382,7 @@ export class ActionFactoryService { children: [], }, { - action: Action.AddTo, + action: Action.Submenu, title: 'Add to', callback: this.dummyCallback, requiresAdmin: false, @@ -397,6 +411,24 @@ export class ActionFactoryService { requiresAdmin: false, children: [], }, + { + action: Action.Submenu, + title: 'Send To', + callback: this.dummyCallback, + requiresAdmin: false, + children: [ + { + action: Action.SendTo, + title: '', + callback: this.dummyCallback, + requiresAdmin: false, + dynamicList: this.deviceService.devices$.pipe(map(devices => devices.map(d => { + return {'title': d.name, 'data': d}; + }), shareReplay())), + children: [] + } + ], + }, ]; this.readingListActions = [ @@ -441,7 +473,7 @@ export class ActionFactoryService { ]; } - private applyCallback(action: ActionItem, callback: (action: Action, data: any) => void) { + private applyCallback(action: ActionItem, callback: (action: ActionItem, data: any) => void) { action.callback = callback; if (action.children === null || action.children?.length === 0) return; @@ -451,7 +483,7 @@ export class ActionFactoryService { }); } - private applyCallbackToList(list: Array>, callback: (action: Action, data: any) => void): Array> { + private applyCallbackToList(list: Array>, callback: (action: ActionItem, data: any) => void): Array> { const actions = list.map((a) => { return { ...a }; }); diff --git a/UI/Web/src/app/_services/device.service.ts b/UI/Web/src/app/_services/device.service.ts new file mode 100644 index 000000000..c33ae2b8c --- /dev/null +++ b/UI/Web/src/app/_services/device.service.ts @@ -0,0 +1,48 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { ReplaySubject, shareReplay, switchMap, take, tap } from 'rxjs'; +import { environment } from 'src/environments/environment'; +import { Device } from '../_models/device/device'; +import { DevicePlatform } from '../_models/device/device-platform'; + +@Injectable({ + providedIn: 'root' +}) +export class DeviceService { + + baseUrl = environment.apiUrl; + + private devicesSource: ReplaySubject = new ReplaySubject(1); + public devices$ = this.devicesSource.asObservable().pipe(shareReplay()); + + + constructor(private httpClient: HttpClient) { + this.httpClient.get(this.baseUrl + 'device', {}).subscribe(data => { + this.devicesSource.next(data); + }); + } + + createDevice(name: string, platform: DevicePlatform, emailAddress: string) { + return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}, {responseType: 'text' as 'json'}); + } + + updateDevice(id: number, name: string, platform: DevicePlatform, emailAddress: string) { + return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}, {responseType: 'text' as 'json'}); + } + + deleteDevice(id: number) { + return this.httpClient.delete(this.baseUrl + 'device?deviceId=' + id); + } + + getDevices() { + return this.httpClient.get(this.baseUrl + 'device', {}).pipe(tap(data => { + this.devicesSource.next(data); + })); + } + + sendTo(chapterId: number, deviceId: number) { + return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterId}, {responseType: 'text' as 'json'}); + } + + +} diff --git a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html index 79a591662..170deef67 100644 --- a/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html +++ b/UI/Web/src/app/admin/manage-email-settings/manage-email-settings.component.html @@ -2,8 +2,11 @@

Email Services (SMTP)

Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own - email service, by setting up Kavita Email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to the default. There is no way to disable emails although you are not required to use a - valid email address for users. Confirmation links will always be saved to logs and presented in the UI. Emails will not be sent if you are not accessing Kavita via a publically reachable url. + email service, by setting up Kavita Email service. Set the url of the email service and use the Test button to ensure it works. + At any time you can reset to the default. There is no way to disable emails for authentication, although you are not required to use a + valid email address for users. Confirmation links will always be saved to logs and presented in the UI. + Registration/Confirmation emails will not be sent if you are not accessing Kavita via a publically reachable url. + If you want Send To device to work, you must host your own email service.

  diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index ae603eda3..e9d0309c3 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -68,7 +68,7 @@   Use debug to help identify issues. Debug can eat up a lot of disk space. Port the server listens on. -
diff --git a/UI/Web/src/app/all-series/all-series.component.ts b/UI/Web/src/app/all-series/all-series.component.ts index 541feac3a..3066d1bc7 100644 --- a/UI/Web/src/app/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/all-series.component.ts @@ -11,7 +11,7 @@ import { JumpKey } from '../_models/jumpbar/jump-key'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; import { FilterEvent, SeriesFilter } from '../_models/series-filter'; -import { Action } from '../_services/action-factory.service'; +import { Action, ActionItem } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service'; import { SeriesService } from '../_services/series.service'; @@ -34,11 +34,11 @@ export class AllSeriesComponent implements OnInit, OnDestroy { filterActive: boolean = false; jumpbarKeys: Array = []; - bulkActionCallback = (action: Action, data: any) => { + bulkActionCallback = (action: ActionItem, data: any) => { const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); - switch (action) { + switch (action.action) { case Action.AddToReadingList: this.actionService.addMultipleSeriesToReadingList(selectedSeries, (success) => { if (success) this.bulkSelectionService.deselectAll(); diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 5047bdaf2..0b6136ac2 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -8,6 +8,7 @@ import { NavService } from './_services/nav.service'; import { filter } from 'rxjs/operators'; import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap'; import { DOCUMENT } from '@angular/common'; +import { DeviceService } from './_services/device.service'; @Component({ selector: 'app-root', diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index 968380027..02c6c85ee 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -12,15 +12,18 @@ import { ErrorInterceptor } from './_interceptors/error.interceptor'; import { SAVER, getSaver } from './shared/_providers/saver.provider'; import { SidenavModule } from './sidenav/sidenav.module'; import { NavModule } from './nav/nav.module'; +import { DevicesComponent } from './devices/devices.component'; // Disable Web Animations if the user's browser (such as iOS 12.5.5) does not support this. const disableAnimations = !('animate' in document.documentElement); if (disableAnimations) console.error("Web Animations have been disabled as your current browser does not support this."); + @NgModule({ declarations: [ AppComponent, + DevicesComponent, ], imports: [ HttpClientModule, diff --git a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts index 3119e712a..42c4cfaee 100644 --- a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts +++ b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts @@ -89,8 +89,8 @@ export class BookmarksComponent implements OnInit, OnDestroy { } } - async handleAction(action: Action, series: Series) { - switch (action) { + async handleAction(action: ActionItem, series: Series) { + switch (action.action) { case(Action.Delete): this.clearBookmarks(series); break; @@ -105,12 +105,12 @@ export class BookmarksComponent implements OnInit, OnDestroy { } } - bulkActionCallback = async (action: Action, data: any) => { + bulkActionCallback = async (action: ActionItem, data: any) => { const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('bookmark'); const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); const seriesIds = selectedSeries.map(item => item.id); - switch (action) { + switch (action.action) { case Action.DownloadBookmark: this.downloadService.download('bookmark', this.bookmarks.filter(bmk => seriesIds.includes(bmk.seriesId)), (d) => { if (!d) { diff --git a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts index 3c75d3ef3..c93a64f5d 100644 --- a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts +++ b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts @@ -11,7 +11,7 @@ import { BulkSelectionService } from '../bulk-selection.service'; }) export class BulkOperationsComponent implements OnInit, OnDestroy { - @Input() actionCallback!: (action: Action, data: any) => void; + @Input() actionCallback!: (action: ActionItem, data: any) => void; topOffset: number = 56; hasMarkAsRead: boolean = false; @@ -41,13 +41,13 @@ export class BulkOperationsComponent implements OnInit, OnDestroy { this.onDestory.complete(); } - handleActionCallback(action: Action, data: any) { + handleActionCallback(action: ActionItem, data: any) { this.actionCallback(action, data); } performAction(action: ActionItem) { if (typeof action.callback === 'function') { - action.callback(action.action, null); + action.callback(action, null); } } diff --git a/UI/Web/src/app/cards/bulk-selection.service.ts b/UI/Web/src/app/cards/bulk-selection.service.ts index c6bf26e5f..8592786e5 100644 --- a/UI/Web/src/app/cards/bulk-selection.service.ts +++ b/UI/Web/src/app/cards/bulk-selection.service.ts @@ -139,7 +139,7 @@ export class BulkSelectionService { return ret; } - getActions(callback: (action: Action, data: any) => void) { + getActions(callback: (action: ActionItem, data: any) => void) { // checks if series is present. If so, returns only series actions // else returns volume/chapter items const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread, Action.AddToCollection, Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList]; diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts index 571c852fa..17769094d 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts @@ -8,6 +8,7 @@ import { DownloadService } from 'src/app/shared/_services/download.service'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { Chapter } from 'src/app/_models/chapter'; import { ChapterMetadata } from 'src/app/_models/chapter-metadata'; +import { Device } from 'src/app/_models/device/device'; import { LibraryType } from 'src/app/_models/library'; import { MangaFile } from 'src/app/_models/manga-file'; import { MangaFormat } from 'src/app/_models/manga-format'; @@ -16,6 +17,7 @@ import { Volume } from 'src/app/_models/volume'; import { AccountService } from 'src/app/_services/account.service'; import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service'; import { ActionService } from 'src/app/_services/action.service'; +import { DeviceService } from 'src/app/_services/device.service'; import { ImageService } from 'src/app/_services/image.service'; import { LibraryService } from 'src/app/_services/library.service'; import { MetadataService } from 'src/app/_services/metadata.service'; @@ -100,7 +102,8 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy { private accountService: AccountService, private actionFactoryService: ActionFactoryService, private actionService: ActionService, private router: Router, private libraryService: LibraryService, private seriesService: SeriesService, private readerService: ReaderService, public metadataService: MetadataService, - public activeOffcanvas: NgbActiveOffcanvas, private downloadService: DownloadService, private readonly cdRef: ChangeDetectorRef) { + public activeOffcanvas: NgbActiveOffcanvas, private downloadService: DownloadService, private readonly cdRef: ChangeDetectorRef, + private deviceSerivce: DeviceService) { this.isAdmin$ = this.accountService.currentUser$.pipe( takeUntil(this.onDestroy), map(user => (user && this.accountService.hasAdminRole(user)) || false), @@ -166,7 +169,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy { performAction(action: ActionItem, chapter: Chapter) { if (typeof action.callback === 'function') { - action.callback(action.action, chapter); + action.callback(action, chapter); } } @@ -196,8 +199,8 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy { this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { this.cdRef.markForCheck(); }); } - handleChapterActionCallback(action: Action, chapter: Chapter) { - switch (action) { + handleChapterActionCallback(action: ActionItem, chapter: Chapter) { + switch (action.action) { case(Action.MarkAsRead): this.markChapterAsRead(chapter); break; @@ -216,6 +219,14 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy { case (Action.Read): this.readChapter(chapter, false); break; + case (Action.SendTo): + { + const device = (action._extra.data as Device); + this.deviceSerivce.sendTo(chapter.id, device.id).subscribe(() => { + this.toastr.success('File emailed to ' + device.name); + }); + break; + } default: break; } diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index e86cd8b4f..5a4184aff 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -135,7 +135,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { performAction(action: ActionItem) { if (typeof action.callback === 'function') { - action.callback(action.action, undefined); + action.callback(action, undefined); } } diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html index 8f11b1027..6415cfc73 100644 --- a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html @@ -1,6 +1,6 @@
- +
@@ -8,10 +8,17 @@ - + + + + + + + + -
+
diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts index fa5cd3c4d..90eec0bb3 100644 --- a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts @@ -34,13 +34,13 @@ export class CardActionablesComponent implements OnInit { }); } - preventClick(event: any) { + preventEvent(event: any) { event.stopPropagation(); event.preventDefault(); } performAction(event: any, action: ActionItem) { - this.preventClick(event); + this.preventEvent(event); if (typeof action.callback === 'function') { this.actionHandler.emit(action); @@ -66,4 +66,9 @@ export class CardActionablesComponent implements OnInit { subMenu.open(); } + performDynamicClick(event: any, action: ActionItem, dynamicItem: any) { + action._extra = dynamicItem; + this.performAction(event, action); + } + } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index a564fa4b4..3f5248578 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -280,12 +280,6 @@ export class CardItemComponent implements OnInit, OnDestroy { performAction(action: ActionItem) { if (action.action == Action.Download) { - - // if (this.download$ !== null) { - // this.toastr.info('Download is already in progress. Please wait.'); - // return; - // } - if (this.utilityService.isVolume(this.entity)) { const volume = this.utilityService.asVolume(this.entity); this.downloadService.download('volume', volume); @@ -300,7 +294,7 @@ export class CardItemComponent implements OnInit, OnDestroy { } if (typeof action.callback === 'function') { - action.callback(action.action, this.entity); + action.callback(action, this.entity); } } diff --git a/UI/Web/src/app/cards/list-item/list-item.component.ts b/UI/Web/src/app/cards/list-item/list-item.component.ts index a2ec8361f..926663925 100644 --- a/UI/Web/src/app/cards/list-item/list-item.component.ts +++ b/UI/Web/src/app/cards/list-item/list-item.component.ts @@ -134,7 +134,7 @@ export class ListItemComponent implements OnInit, OnDestroy { } if (typeof action.callback === 'function') { - action.callback(action.action, this.entity); + action.callback(action, this.entity); } } } diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index 40767c661..007040cc5 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -64,7 +64,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { ngOnChanges(changes: any) { if (this.data) { - this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series)); + this.actions = this.actionFactoryService.getSeriesActions((action: ActionItem, series: Series) => this.handleSeriesActionCallback(action, series)); this.cdRef.markForCheck(); } } @@ -74,8 +74,8 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { this.onDestroy.complete(); } - handleSeriesActionCallback(action: Action, series: Series) { - switch (action) { + handleSeriesActionCallback(action: ActionItem, series: Series) { + switch (action.action) { case(Action.MarkAsRead): this.markAsRead(series); break; diff --git a/UI/Web/src/app/collections/all-collections/all-collections.component.ts b/UI/Web/src/app/collections/all-collections/all-collections.component.ts index 97560267c..02588d01a 100644 --- a/UI/Web/src/app/collections/all-collections/all-collections.component.ts +++ b/UI/Web/src/app/collections/all-collections/all-collections.component.ts @@ -59,8 +59,8 @@ export class AllCollectionsComponent implements OnInit { }); } - handleCollectionActionCallback(action: Action, collectionTag: CollectionTag) { - switch (action) { + handleCollectionActionCallback(action: ActionItem, collectionTag: CollectionTag) { + switch (action.action) { case(Action.Edit): const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true }); modalRef.componentInstance.tag = collectionTag; diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts index 8c5b270b5..b105f7007 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts @@ -58,11 +58,11 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten private onDestory: Subject = new Subject(); - bulkActionCallback = (action: Action, data: any) => { + bulkActionCallback = (action: ActionItem, data: any) => { const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); - switch (action) { + switch (action.action) { case Action.AddToReadingList: this.actionService.addMultipleSeriesToReadingList(selectedSeries, (success) => { if (success) this.bulkSelectionService.deselectAll(); @@ -224,8 +224,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten this.loadPage(); } - handleCollectionActionCallback(action: Action, collectionTag: CollectionTag) { - switch (action) { + handleCollectionActionCallback(action: ActionItem, collectionTag: CollectionTag) { + switch (action.action) { case(Action.Edit): this.openEditCollectionTagModal(this.collectionTag); break; @@ -236,7 +236,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten performAction(action: ActionItem) { if (typeof action.callback === 'function') { - action.callback(action.action, this.collectionTag); + action.callback(action, this.collectionTag); } } diff --git a/UI/Web/src/app/devices/devices.component.html b/UI/Web/src/app/devices/devices.component.html new file mode 100644 index 000000000..c68e3d81f --- /dev/null +++ b/UI/Web/src/app/devices/devices.component.html @@ -0,0 +1 @@ +

devices works!

diff --git a/UI/Web/src/app/devices/devices.component.scss b/UI/Web/src/app/devices/devices.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/devices/devices.component.ts b/UI/Web/src/app/devices/devices.component.ts new file mode 100644 index 000000000..f635bded6 --- /dev/null +++ b/UI/Web/src/app/devices/devices.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-devices', + templateUrl: './devices.component.html', + styleUrls: ['./devices.component.scss'] +}) +export class DevicesComponent implements OnInit { + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index 9d28d516f..689fa328f 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -52,11 +52,11 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { active = this.tabs[0]; - bulkActionCallback = (action: Action, data: any) => { + bulkActionCallback = (action: ActionItem, data: any) => { const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); - switch (action) { + switch (action.action) { case Action.AddToReadingList: this.actionService.addMultipleSeriesToReadingList(selectedSeries, (success) => { if (success) this.bulkSelectionService.deselectAll(); @@ -197,12 +197,12 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { } } - handleAction(action: Action, library: Library) { + handleAction(action: ActionItem, library: Library) { let lib: Partial = library; if (library === undefined) { lib = {id: this.libraryId, name: this.libraryName}; } - switch (action) { + switch (action.action) { case(Action.Scan): this.actionService.scanLibrary(lib); break; @@ -216,7 +216,7 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { performAction(action: ActionItem) { if (typeof action.callback === 'function') { - action.callback(action.action, undefined); + action.callback(action, undefined); } } diff --git a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts index d2ae20323..67b6f611a 100644 --- a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts @@ -109,7 +109,7 @@ export class ReadingListDetailComponent implements OnInit { performAction(action: ActionItem) { if (typeof action.callback === 'function') { - action.callback(action.action, this.readingList); + action.callback(action, this.readingList); } } @@ -123,8 +123,8 @@ export class ReadingListDetailComponent implements OnInit { this.router.navigate(this.readerService.getNavigationArray(item.libraryId, item.seriesId, item.chapterId, item.seriesFormat), {queryParams: params}); } - handleReadingListActionCallback(action: Action, readingList: ReadingList) { - switch(action) { + handleReadingListActionCallback(action: ActionItem, readingList: ReadingList) { + switch(action.action) { case Action.Delete: this.deleteList(readingList); break; diff --git a/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts b/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts index 09e4da7fd..758bd7aff 100644 --- a/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts +++ b/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts @@ -44,14 +44,14 @@ export class ReadingListsComponent implements OnInit { .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); } - performAction(action: ActionItem, readingList: ReadingList) { + performAction(action: ActionItem, readingList: ReadingList) { if (typeof action.callback === 'function') { - action.callback(action.action, readingList); + action.callback(action, readingList); } } - handleReadingListActionCallback(action: Action, readingList: ReadingList) { - switch(action) { + handleReadingListActionCallback(action: ActionItem, readingList: ReadingList) { + switch(action.action) { case Action.Delete: this.readingListService.delete(readingList.id).subscribe(() => { this.toastr.success('Reading list deleted'); diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index 1e74c104f..4f3d91d76 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -40,6 +40,8 @@ import { PageLayoutMode } from '../_models/page-layout-mode'; import { DOCUMENT } from '@angular/common'; import { User } from '../_models/user'; import { ScrollService } from '../_services/scroll.service'; +import { DeviceService } from '../_services/device.service'; +import { Device } from '../_models/device/device'; interface RelatedSeris { series: Series; @@ -158,7 +160,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe isAscendingSort: boolean = false; // TODO: Get this from User preferences user: User | undefined; - bulkActionCallback = (action: Action, data: any) => { + bulkActionCallback = (action: ActionItem, data: any) => { if (this.series === undefined) { return; } @@ -177,7 +179,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe const selectedSpecials = this.specials.filter((_chapter, index: number) => selectedSpecialIndexes.includes(index + '')); const chapters = [...selectedChapterIds, ...selectedSpecials]; - switch (action) { + switch (action.action) { case Action.AddToReadingList: this.actionService.addMultipleToReadingList(seriesId, selectedVolumeIds, chapters, (success) => { this.actionInProgress = false; @@ -250,7 +252,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe public imageSerivce: ImageService, private messageHub: MessageHubService, private readingListService: ReadingListService, public navService: NavService, private offcanvasService: NgbOffcanvas, @Inject(DOCUMENT) private document: Document, - private changeDetectionRef: ChangeDetectorRef, private scrollService: ScrollService + private changeDetectionRef: ChangeDetectorRef, private scrollService: ScrollService, + private deviceSerivce: DeviceService ) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.accountService.currentUser$.pipe(take(1)).subscribe(user => { @@ -330,10 +333,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe this.changeDetectionRef.markForCheck(); } - handleSeriesActionCallback(action: Action, series: Series) { + handleSeriesActionCallback(action: ActionItem, series: Series) { this.actionInProgress = true; this.changeDetectionRef.markForCheck(); - switch(action) { + switch(action.action) { case(Action.MarkAsRead): this.actionService.markSeriesAsRead(series, (series: Series) => { this.actionInProgress = false; @@ -400,8 +403,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe } } - handleVolumeActionCallback(action: Action, volume: Volume) { - switch(action) { + handleVolumeActionCallback(action: ActionItem, volume: Volume) { + switch(action.action) { case(Action.MarkAsRead): this.markVolumeAsRead(volume); break; @@ -424,8 +427,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe } } - handleChapterActionCallback(action: Action, chapter: Chapter) { - switch (action) { + handleChapterActionCallback(action: ActionItem, chapter: Chapter) { + switch (action.action) { case(Action.MarkAsRead): this.markChapterAsRead(chapter); break; @@ -441,6 +444,14 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe case(Action.IncognitoRead): this.openChapter(chapter, true); break; + case (Action.SendTo): + { + const device = (action._extra.data as Device); + this.deviceSerivce.sendTo(chapter.id, device.id).subscribe(() => { + this.toastr.success('File emailed to ' + device.name); + }); + break; + } default: break; } @@ -748,7 +759,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe performAction(action: ActionItem) { if (typeof action.callback === 'function') { - action.callback(action.action, this.series); + action.callback(action, this.series); } } diff --git a/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts index 0635ec55a..a2e10c957 100644 --- a/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/side-nav/side-nav.component.ts @@ -76,8 +76,8 @@ export class SideNavComponent implements OnInit, OnDestroy { this.onDestroy.complete(); } - handleAction(action: Action, library: Library) { - switch (action) { + handleAction(action: ActionItem, library: Library) { + switch (action.action) { case(Action.Scan): this.actionService.scanLibrary(library); break; @@ -95,7 +95,7 @@ export class SideNavComponent implements OnInit, OnDestroy { performAction(action: ActionItem, library: Library) { if (typeof action.callback === 'function') { - action.callback(action.action, library); + action.callback(action, library); } } diff --git a/UI/Web/src/app/user-settings/_pipes/device-platform.pipe.ts b/UI/Web/src/app/user-settings/_pipes/device-platform.pipe.ts new file mode 100644 index 000000000..26a45a765 --- /dev/null +++ b/UI/Web/src/app/user-settings/_pipes/device-platform.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { DevicePlatform } from 'src/app/_models/device/device-platform'; + +@Pipe({ + name: 'devicePlatform' +}) +export class DevicePlatformPipe implements PipeTransform { + + transform(value: DevicePlatform): string { + switch(value) { + case DevicePlatform.Kindle: return 'Kindle'; + case DevicePlatform.Kobo: return 'Kobo'; + case DevicePlatform.PocketBook: return 'PocketBook'; + case DevicePlatform.Custom: return 'Custom'; + default: return value + ''; + } + } + +} diff --git a/UI/Web/src/app/user-settings/edit-device/edit-device.component.html b/UI/Web/src/app/user-settings/edit-device/edit-device.component.html new file mode 100644 index 000000000..37a3e5d56 --- /dev/null +++ b/UI/Web/src/app/user-settings/edit-device/edit-device.component.html @@ -0,0 +1,45 @@ +
+ +
+
+ + + +

+ This field is required +

+
+
+ +
+   + This email will be used to accept the file via Send To + The number of backups to maintain. Default is 30, minumum is 1, maximum is 30. + + +

+ This must be a valid email +

+

+ This field is required +

+
+
+ +
+ + + +

+ This field is required +

+
+
+
+
+ +
+ +
\ No newline at end of file diff --git a/UI/Web/src/app/user-settings/edit-device/edit-device.component.scss b/UI/Web/src/app/user-settings/edit-device/edit-device.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/user-settings/edit-device/edit-device.component.ts b/UI/Web/src/app/user-settings/edit-device/edit-device.component.ts new file mode 100644 index 000000000..709614326 --- /dev/null +++ b/UI/Web/src/app/user-settings/edit-device/edit-device.component.ts @@ -0,0 +1,81 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { Subject, takeUntil } from 'rxjs'; +import { Device } from 'src/app/_models/device/device'; +import { DevicePlatform, devicePlatforms } from 'src/app/_models/device/device-platform'; +import { DeviceService } from 'src/app/_services/device.service'; + +@Component({ + selector: 'app-edit-device', + templateUrl: './edit-device.component.html', + styleUrls: ['./edit-device.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditDeviceComponent implements OnInit, OnChanges, OnDestroy { + + @Input() device: Device | undefined; + + @Output() deviceAdded: EventEmitter = new EventEmitter(); + @Output() deviceUpdated: EventEmitter = new EventEmitter(); + + settingsForm: FormGroup = new FormGroup({}); + devicePlatforms = devicePlatforms; + + private readonly onDestroy = new Subject(); + + constructor(public deviceService: DeviceService, private toastr: ToastrService, + private readonly cdRef: ChangeDetectorRef) { } + + ngOnInit(): void { + + this.settingsForm.addControl('name', new FormControl(this.device?.name || '', [Validators.required])); + this.settingsForm.addControl('email', new FormControl(this.device?.emailAddress || '', [Validators.required, Validators.email])); + this.settingsForm.addControl('platform', new FormControl(this.device?.platform || DevicePlatform.Custom, [Validators.required])); + + // If user has filled in email and the platform hasn't been explicitly updated, try to update it for them + this.settingsForm.get('email')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(email => { + if (this.settingsForm.get('platform')?.dirty) return; + if (email === null || email === undefined || email === '') return; + if (email.endsWith('@kindle.com')) this.settingsForm.get('platform')?.setValue(DevicePlatform.Kindle); + else if (email.endsWith('@pbsync.com')) this.settingsForm.get('platform')?.setValue(DevicePlatform.PocketBook); + else this.settingsForm.get('platform')?.setValue(DevicePlatform.Custom); + this.cdRef.markForCheck(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (this.device) { + this.settingsForm.get('name')?.setValue(this.device.name); + this.settingsForm.get('email')?.setValue(this.device.emailAddress); + this.settingsForm.get('platform')?.setValue(this.device.platform); + this.cdRef.markForCheck(); + this.settingsForm.markAsPristine(); + } + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + addDevice() { + if (this.device !== undefined) { + this.deviceService.updateDevice(this.device.id, this.settingsForm.value.name, this.settingsForm.value.platform, this.settingsForm.value.email).subscribe(() => { + this.settingsForm.reset(); + this.toastr.success('Device updated'); + this.cdRef.markForCheck(); + this.deviceUpdated.emit(); + }) + return; + } + + this.deviceService.createDevice(this.settingsForm.value.name, this.settingsForm.value.platform, this.settingsForm.value.email).subscribe(() => { + this.settingsForm.reset(); + this.toastr.success('Device created'); + this.cdRef.markForCheck(); + this.deviceAdded.emit(); + }); + } + +} diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html new file mode 100644 index 000000000..5c97de9cc --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html @@ -0,0 +1,39 @@ +
+
+

Device Manager

+
+ +
+
+ +

+ This section is for you to setup devices that cannot connect to Kavita via a web browser and instead have an email address that accepts files. +

+ +
+ +
+ +
+

Devices

+

+ There are no devices setup yet +

+ +
+
+
{{device.name | sentenceCase}}
+ Platform:
{{device.platform | devicePlatform}}
+ Email:
{{device.emailAddress}}
+ + + +
+
+
+
+
+ diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.scss b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts new file mode 100644 index 000000000..e06a9e2a1 --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { Subject, takeUntil } from 'rxjs'; +import { Device } from 'src/app/_models/device/device'; +import { DevicePlatform, devicePlatforms } from 'src/app/_models/device/device-platform'; +import { DeviceService } from 'src/app/_services/device.service'; + +@Component({ + selector: 'app-manage-devices', + templateUrl: './manage-devices.component.html', + styleUrls: ['./manage-devices.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ManageDevicesComponent implements OnInit, OnDestroy { + + devices: Array = []; + addDeviceIsCollapsed: boolean = true; + device: Device | undefined; + + + private readonly onDestroy = new Subject(); + + constructor(public deviceService: DeviceService, private toastr: ToastrService, + private readonly cdRef: ChangeDetectorRef) { } + + ngOnInit(): void { + this.loadDevices(); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + loadDevices() { + this.addDeviceIsCollapsed = true; + this.device = undefined; + this.cdRef.markForCheck(); + this.deviceService.getDevices().subscribe(devices => { + this.devices = devices; + this.cdRef.markForCheck(); + }); + } + + deleteDevice(device: Device) { + this.deviceService.deleteDevice(device.id).subscribe(() => { + const index = this.devices.indexOf(device); + this.devices.splice(index, 1); + this.cdRef.markForCheck(); + }); + } + + editDevice(device: Device) { + this.device = device; + this.addDeviceIsCollapsed = false; + this.cdRef.markForCheck(); + } +} diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index 82f049f03..26a9018b4 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -335,6 +335,9 @@ + + + diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index efbcb9599..7dfe2ffd8 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -52,6 +52,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { {title: 'Password', fragment: 'password'}, {title: '3rd Party Clients', fragment: 'clients'}, {title: 'Theme', fragment: 'theme'}, + {title: 'Devices', fragment: 'devices'}, ]; active = this.tabs[0]; opdsEnabled: boolean = false; diff --git a/UI/Web/src/app/user-settings/user-settings.module.ts b/UI/Web/src/app/user-settings/user-settings.module.ts index 856924289..a8f7d52e5 100644 --- a/UI/Web/src/app/user-settings/user-settings.module.ts +++ b/UI/Web/src/app/user-settings/user-settings.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { UserPreferencesComponent } from './user-preferences/user-preferences.component'; -import { NgbAccordionModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbAccordionModule, NgbCollapseModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { ReactiveFormsModule } from '@angular/forms'; import { UserSettingsRoutingModule } from './user-settings-routing.module'; import { ApiKeyComponent } from './api-key/api-key.component'; @@ -10,7 +10,9 @@ import { SiteThemeProviderPipe } from './_pipes/site-theme-provider.pipe'; import { ThemeManagerComponent } from './theme-manager/theme-manager.component'; import { ColorPickerModule } from 'ngx-color-picker'; import { SidenavModule } from '../sidenav/sidenav.module'; - +import { ManageDevicesComponent } from './manage-devices/manage-devices.component'; +import { DevicePlatformPipe } from './_pipes/device-platform.pipe'; +import { EditDeviceComponent } from './edit-device/edit-device.component'; @NgModule({ @@ -19,6 +21,9 @@ import { SidenavModule } from '../sidenav/sidenav.module'; ApiKeyComponent, ThemeManagerComponent, SiteThemeProviderPipe, + ManageDevicesComponent, + DevicePlatformPipe, + EditDeviceComponent, ], imports: [ CommonModule, @@ -27,6 +32,7 @@ import { SidenavModule } from '../sidenav/sidenav.module'; NgbAccordionModule, NgbNavModule, NgbTooltipModule, + NgbCollapseModule, ColorPickerModule, // User prefernces background color diff --git a/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.ts b/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.ts index 7d38a4788..6bf90d725 100644 --- a/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.ts +++ b/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.ts @@ -12,7 +12,7 @@ import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; import { Pagination } from 'src/app/_models/pagination'; import { Series } from 'src/app/_models/series'; import { SeriesFilter, FilterEvent } from 'src/app/_models/series-filter'; -import { Action } from 'src/app/_services/action-factory.service'; +import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import { ActionService } from 'src/app/_services/action.service'; import { ImageService } from 'src/app/_services/image.service'; import { MessageHubService, EVENTS } from 'src/app/_services/message-hub.service'; @@ -48,11 +48,11 @@ export class WantToReadComponent implements OnInit, OnDestroy { private onDestroy: Subject = new Subject(); trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`; - bulkActionCallback = (action: Action, data: any) => { + bulkActionCallback = (action: ActionItem, data: any) => { const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); - switch (action) { + switch (action.action) { case Action.RemoveFromWantToReadList: this.actionService.removeMultipleSeriesFromWantToReadList(selectedSeries.map(s => s.id), () => { this.bulkSelectionService.deselectAll(); diff --git a/UI/Web/src/assets/images/sendto/kindle-icon.png b/UI/Web/src/assets/images/sendto/kindle-icon.png new file mode 100644 index 000000000..a251d1057 Binary files /dev/null and b/UI/Web/src/assets/images/sendto/kindle-icon.png differ diff --git a/UI/Web/src/assets/images/sendto/pocketbook-icon.png b/UI/Web/src/assets/images/sendto/pocketbook-icon.png new file mode 100644 index 000000000..d45537c4c Binary files /dev/null and b/UI/Web/src/assets/images/sendto/pocketbook-icon.png differ