Send To Device Support (#1557)

* Tweaked the logging output

* Started implementing some basic idea for devices

* Updated Email Service with new API routes

* Implemented basic DB structure and some APIs to prep for the UI and flows.

* Added an abstract class to make Unit testing easier.

* Removed dependency we don't need

* Updated the UI to be able to show devices and add new devices. Email field will update the platform if the user hasn't interacted with it already.

* Added ability to delete a device as well

* Basic ability to send files to devices works

* Refactored Action code to pass ActionItem back and allow for dynamic children based on an Observable (api).

Hooked in ability to send a chapter to a device. There is no logic in the FE to validate type.

* Fixed a broken unit test

* Implemented the ability to edit a device

* Code cleanup

* Fixed a bad success message

* Fixed broken unit test from updating mock layer
This commit is contained in:
Joseph Milazzo 2022-09-23 17:41:29 -05:00 committed by GitHub
parent ab0f13ef74
commit 9d7476a367
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 3026 additions and 157 deletions

View File

@ -10,7 +10,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
<PackageReference Include="NSubstitute" Version="4.4.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="17.2.1" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="17.2.3" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

118
API.Tests/BasicTest.cs Normal file
View File

@ -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<AutoMapperProfiles>());
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<bool> SeedDb()
{
await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), 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<FolderPath>()
{
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;
}
}

View File

@ -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<string, MockFileData>
{
@ -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));
}
}

View File

@ -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<DeviceService> _logger = Substitute.For<ILogger<DeviceService>>();
private readonly IDeviceService _deviceService;
public DeviceServiceTests() : base()
{
_deviceService = new DeviceService(_unitOfWork, _logger, Substitute.For<IEmailService>());
}
protected void ResetDB()
{
_context.Users.RemoveRange(_context.Users.ToList());
}
[Fact]
public async Task CreateDevice_Succeeds()
{
var user = new AppUser()
{
UserName = "majora2007",
Devices = new List<Device>()
};
_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<Device>()
};
_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);
}
}

View File

@ -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;
/// <summary>
/// Responsible interacting and creating Devices
/// </summary>
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<ActionResult> 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<ActionResult> 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();
}
/// <summary>
/// Deletes the device from the user
/// </summary>
/// <param name="deviceId"></param>
/// <returns></returns>
[HttpDelete]
public async Task<ActionResult> 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<ActionResult<IEnumerable<DeviceDto>>> GetDevices()
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(userId));
}
[HttpPost("send-to")]
public async Task<ActionResult> 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");
}
}

View File

@ -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;

View File

@ -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; }
/// <summary>
/// Platform of the device. If not know, defaults to "Custom"
/// </summary>
[Required]
public DevicePlatform Platform { get; set; }
[Required]
public string EmailAddress { get; set; }
}

View File

@ -0,0 +1,33 @@
using System;
using API.Entities.Enums.Device;
namespace API.DTOs.Device;
/// <summary>
/// A Device is an entity that can receive data from Kavita (kindle)
/// </summary>
public class DeviceDto
{
/// <summary>
/// The device Id
/// </summary>
public int Id { get; set; }
/// <summary>
/// A name given to this device
/// </summary>
/// <remarks>If this device is web, this will be the browser name</remarks>
/// <example>Pixel 3a, John's Kindle</example>
public string Name { get; set; }
/// <summary>
/// An email address associated with the device (ie Kindle). Will be used with Send to functionality
/// </summary>
public string EmailAddress { get; set; }
/// <summary>
/// Platform (ie) Windows 10
/// </summary>
public DevicePlatform Platform { get; set; }
/// <summary>
/// Last time this device was used to send a file
/// </summary>
public DateTime LastUsed { get; set; }
}

View File

@ -0,0 +1,7 @@
namespace API.DTOs.Device;
public class SendToDeviceDto
{
public int DeviceId { get; set; }
public int ChapterId { get; set; }
}

View File

@ -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; }
/// <summary>
/// Platform of the device. If not know, defaults to "Custom"
/// </summary>
[Required]
public DevicePlatform Platform { get; set; }
[Required]
public string EmailAddress { get; set; }
}

View File

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.Email;
public class SendToDto
{
public string DestinationEmail { get; set; }
public IEnumerable<string> FilePaths { get; set; }
}

View File

@ -44,6 +44,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<SiteTheme> SiteTheme { get; set; }
public DbSet<SeriesRelation> SeriesRelation { get; set; }
public DbSet<FolderPath> FolderPath { get; set; }
public DbSet<Device> Device { get; set; }
protected override void OnModelCreating(ModelBuilder builder)

View File

@ -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,
};
}

File diff suppressed because it is too large Load Diff

View File

@ -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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
IpAddress = table.Column<string>(type: "TEXT", nullable: true),
Name = table.Column<string>(type: "TEXT", nullable: true),
EmailAddress = table.Column<string>(type: "TEXT", nullable: true),
Platform = table.Column<int>(type: "INTEGER", nullable: false),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false),
LastUsed = table.Column<DateTime>(type: "TEXT", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(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);
}
}
}

View File

@ -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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("EmailAddress")
.HasColumnType("TEXT");
b.Property<string>("IpAddress")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastUsed")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Platform")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("Device");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("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");

View File

@ -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<IEnumerable<DeviceDto>> GetDevicesForUserAsync(int userId);
Task<Device> 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<IEnumerable<DeviceDto>> GetDevicesForUserAsync(int userId)
{
return await _context.Device
.Where(d => d.AppUserId == userId)
.OrderBy(d => d.LastUsed)
.ProjectTo<DeviceDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<Device> GetDeviceById(int deviceId)
{
return await _context.Device
.Where(d => d.Id == deviceId)
.SingleOrDefaultAsync();
}
}

View File

@ -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;

View File

@ -24,6 +24,7 @@ public interface IUnitOfWork
ITagRepository TagRepository { get; }
ISiteThemeRepository SiteThemeRepository { get; }
IMangaFileRepository MangaFileRepository { get; }
IDeviceRepository DeviceRepository { get; }
bool Commit();
Task<bool> 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);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View File

@ -16,6 +16,9 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
public ICollection<AppUserProgress> Progresses { get; set; }
public ICollection<AppUserRating> Ratings { get; set; }
public AppUserPreferences UserPreferences { get; set; }
/// <summary>
/// Bookmarks associated with this User
/// </summary>
public ICollection<AppUserBookmark> Bookmarks { get; set; }
/// <summary>
/// Reading lists associated with this user
@ -26,6 +29,10 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// </summary>
public ICollection<Series> WantToRead { get; set; }
/// <summary>
/// A list of Devices which allows the user to send files to
/// </summary>
public ICollection<Device> Devices { get; set; }
/// <summary>
/// An API Key to interact with external services, like OPDS
/// </summary>
public string ApiKey { get; set; }

49
API/Entities/Device.cs Normal file
View File

@ -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;
/// <summary>
/// A Device is an entity that can receive data from Kavita (kindle)
/// </summary>
public class Device : IEntityDate
{
public int Id { get; set; }
/// <summary>
/// Last Seen IP Address of the device
/// </summary>
public string IpAddress { get; set; }
/// <summary>
/// A name given to this device
/// </summary>
/// <remarks>If this device is web, this will be the browser name</remarks>
/// <example>Pixel 3a, John's Kindle</example>
public string Name { get; set; }
/// <summary>
/// An email address associated with the device (ie Kindle). Will be used with Send to functionality
/// </summary>
public string EmailAddress { get; set; }
/// <summary>
/// Platform (ie) Windows 10
/// </summary>
public DevicePlatform Platform { get; set; }
//public ICollection<string> 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; }
/// <summary>
/// Last time this device was used to send a file
/// </summary>
public DateTime LastUsed { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
}

View File

@ -0,0 +1,25 @@
using System.ComponentModel;
namespace API.Entities.Enums.Device;
public enum DevicePlatform
{
[Description("Custom")]
Custom = 0,
/// <summary>
/// PocketBook device, email ends in @pbsync.com
/// </summary>
[Description("PocketBook")]
PocketBook = 1,
/// <summary>
/// Kindle device, email ends in @kindle.com
/// </summary>
[Description("Kindle")]
Kindle = 2,
/// <summary>
/// Kobo device,
/// </summary>
[Description("Kobo")]
Kobo = 3,
}

View File

@ -8,18 +8,18 @@ namespace API.Entities.Metadata;
/// A relation flows between one series and another.
/// Series ---kind---> target
/// </summary>
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; }
/// <summary>
/// A is Sequel to B. In this example, TargetSeries is A. B will hold the foreign key.
/// </summary>
public int TargetSeriesId { get; set; }
// Relationships
public virtual Series Series { get; set; }
public Series Series { get; set; }
public int SeriesId { get; set; }
}

View File

@ -49,6 +49,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<ISeriesService, SeriesService>();
services.AddScoped<IProcessSeries, ProcessSeries>();
services.AddScoped<IReadingListService, ReadingListService>();
services.AddScoped<IDeviceService, DeviceService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IMetadataService, MetadataService>();

View File

@ -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<SeriesRelation, RelatedSeriesDto>()
// .ForMember(dest => dest.Adaptations,
// opt =>
// opt.MapFrom(src => src.Where(p => p.Role == PersonRole.Writer)))
CreateMap<AppUser, UserDto>();
CreateMap<SiteTheme, SiteThemeDto>();
CreateMap<AppUserPreferences, UserPreferencesDto>()
@ -144,5 +140,7 @@ public class AutoMapperProfiles : Profile
CreateMap<IEnumerable<ServerSetting>, ServerSettingDto>()
.ConvertUsing<ServerSettingConverter>();
CreateMap<Device, DeviceDto>();
}
}

View File

@ -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
}
/// <summary>
/// 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
/// </summary>
/// <param name="chapter"></param>
/// <param name="forceUpdate"></param>
/// <param name="firstFile"></param>
/// <returns></returns>
public bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile)
public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile)
{
return firstFile != null &&
(!forceUpdate &&

View File

@ -117,7 +117,7 @@ public class Program
Log.Fatal(ex, "Host terminated unexpectedly");
} finally
{
Log.CloseAndFlush();
await Log.CloseAndFlushAsync();
}
}

View File

@ -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<Device> Create(CreateDeviceDto dto, AppUser userWithDevices);
Task<Device> Update(UpdateDeviceDto dto, AppUser userWithDevices);
Task<bool> Delete(AppUser userWithDevices, int deviceId);
Task<bool> SendTo(int chapterId, int deviceId);
}
public class DeviceService : IDeviceService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<DeviceService> _logger;
private readonly IEmailService _emailService;
public DeviceService(IUnitOfWork unitOfWork, ILogger<DeviceService> logger, IEmailService emailService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_emailService = emailService;
}
#nullable enable
public async Task<Device?> Create(CreateDeviceDto dto, AppUser userWithDevices)
{
try
{
userWithDevices.Devices ??= new List<Device>();
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<Device?> 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<bool> 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<bool> 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;
}
}

View File

@ -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<bool> CheckIfAccessible(string host);
Task<bool> SendMigrationEmail(EmailMigrationDto data);
Task<bool> SendPasswordResetEmail(PasswordResetEmailDto data);
Task<bool> SendFilesToEmail(SendToDto data);
Task<EmailTestResultDto> TestConnectivity(string emailUrl);
Task<bool> IsDefaultEmailService();
}
public class EmailService : IEmailService
{
private readonly ILogger<EmailService> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly IDownloadService _downloadService;
/// <summary>
/// This is used to initially set or reset the ServerSettingKey. Do not access from the code, access via UnitOfWork
/// </summary>
public const string DefaultApiUrl = "https://email.kavitareader.com";
public EmailService(ILogger<EmailService> logger, IUnitOfWork unitOfWork)
public EmailService(ILogger<EmailService> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> SendEmailWithGet(string url, int timeoutSecs = 30)
@ -156,6 +177,41 @@ public class EmailService : IEmailService
return true;
}
private async Task<bool> SendEmailWithFiles(string url, IEnumerable<string> 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];

View File

@ -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();
}

View File

@ -40,8 +40,8 @@ public class ReaderService : IReaderService
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderService> _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;

View File

@ -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();

View File

@ -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)

View File

@ -53,6 +53,7 @@ public class Startup
public void ConfigureServices(IServiceCollection services)
{
services.AddApplicationServices(_config, _env);
services.AddControllers(options =>
{
options.CacheProfiles.Add("Images",

View File

@ -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<AssemblyConfigurationAttribute>().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; }
}

View File

@ -14,7 +14,7 @@
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.44.0.52574">
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.45.0.54064">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -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];

View File

@ -0,0 +1,9 @@
import { DevicePlatform } from "./device-platform";
export interface Device {
id: number;
name: string;
platform: DevicePlatform;
emailAddress: string;
lastUsed: string;
}

View File

@ -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<string[]>(this.baseUrl + 'account/roles');
}
login(model: {username: string, password: string}): Observable<any> {
login(model: {username: string, password: string}) {
return this.httpClient.post<User>(this.baseUrl + 'account/login', model).pipe(
map((response: User) => {
const user = response;

View File

@ -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<T> {
title: string;
action: Action;
callback: (action: Action, data: T) => void;
callback: (action: ActionItem<T>, data: T) => void;
requiresAdmin: boolean;
children: Array<ActionItem<T>>;
/**
* 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: Library) => void) {
return this.applyCallbackToList(this.libraryActions, callback);
}
getSeriesActions(callback: (action: Action, series: Series) => void) {
getSeriesActions(callback: (action: ActionItem<Series>, series: Series) => void) {
return this.applyCallbackToList(this.seriesActions, callback);
}
getVolumeActions(callback: (action: Action, volume: Volume) => void) {
getVolumeActions(callback: (action: ActionItem<Volume>, volume: Volume) => void) {
return this.applyCallbackToList(this.volumeActions, callback);
}
getChapterActions(callback: (action: Action, chapter: Chapter) => void) {
getChapterActions(callback: (action: ActionItem<Chapter>, chapter: Chapter) => void) {
return this.applyCallbackToList(this.chapterActions, callback);
}
getCollectionTagActions(callback: (action: Action, collectionTag: CollectionTag) => void) {
getCollectionTagActions(callback: (action: ActionItem<CollectionTag>, collectionTag: CollectionTag) => void) {
return this.applyCallbackToList(this.collectionTagActions, callback);
}
getReadingListActions(callback: (action: Action, readingList: ReadingList) => void) {
getReadingListActions(callback: (action: ActionItem<ReadingList>, readingList: ReadingList) => void) {
return this.applyCallbackToList(this.readingListActions, callback);
}
getBookmarkActions(callback: (action: Action, series: Series) => void) {
getBookmarkActions(callback: (action: ActionItem<Series>, series: Series) => void) {
return this.applyCallbackToList(this.bookmarkActions, callback);
}
dummyCallback(action: Action, data: any) {}
dummyCallback(action: ActionItem<any>, 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<any>, callback: (action: Action, data: any) => void) {
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, 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<ActionItem<any>>, callback: (action: Action, data: any) => void): Array<ActionItem<any>> {
private applyCallbackToList(list: Array<ActionItem<any>>, callback: (action: ActionItem<any>, data: any) => void): Array<ActionItem<any>> {
const actions = list.map((a) => {
return { ...a };
});

View File

@ -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<Device[]> = new ReplaySubject<Device[]>(1);
public devices$ = this.devicesSource.asObservable().pipe(shareReplay());
constructor(private httpClient: HttpClient) {
this.httpClient.get<Device[]>(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<Device[]>(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'});
}
}

View File

@ -2,8 +2,11 @@
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<h4>Email Services (SMTP)</h4>
<p>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 <a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a> 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 <a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a> 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.
<span class="text-warning">If you want Send To device to work, you must host your own email service.</span>
</p>
<div class="mb-3">
<label for="settings-emailservice" class="form-label">Email Service Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>

View File

@ -68,7 +68,7 @@
<label for="logging-level-port" class="form-label">Logging Level</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space.</ng-template>
<span class="visually-hidden" id="logging-level-port-help">Port the server listens on.</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="loggingLevel">
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-select" formControlName="loggingLevel">
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
</select>
</div>

View File

@ -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<JumpKey> = [];
bulkActionCallback = (action: Action, data: any) => {
bulkActionCallback = (action: ActionItem<any>, 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();

View File

@ -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',

View File

@ -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,

View File

@ -89,8 +89,8 @@ export class BookmarksComponent implements OnInit, OnDestroy {
}
}
async handleAction(action: Action, series: Series) {
switch (action) {
async handleAction(action: ActionItem<Series>, 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<any>, 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) {

View File

@ -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<any>, 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<any>, data: any) {
this.actionCallback(action, data);
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, null);
action.callback(action, null);
}
}

View File

@ -139,7 +139,7 @@ export class BulkSelectionService {
return ret;
}
getActions(callback: (action: Action, data: any) => void) {
getActions(callback: (action: ActionItem<any>, 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];

View File

@ -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<any>, 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: 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;
}

View File

@ -135,7 +135,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, undefined);
action.callback(action, undefined);
}
}

View File

@ -1,6 +1,6 @@
<ng-container *ngIf="actions.length > 0">
<div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle (click)="preventClick($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle (click)="preventEvent($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
</div>
@ -8,10 +8,17 @@
<ng-template #submenu let-list="list">
<ng-container *ngFor="let action of list">
<ng-container *ngIf="action.children === undefined || action?.children?.length === 0 else submenuDropdown">
<button ngbDropdownItem *ngIf="willRenderAction(action)" (click)="performAction($event, action)">{{action.title}}</button>
<ng-container *ngIf="action.dynamicList != undefined; else justItem">
<ng-container *ngFor="let dynamicItem of (action.dynamicList | async)">
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
</ng-container>
</ng-container>
<ng-template #justItem>
<button ngbDropdownItem *ngIf="willRenderAction(action)" (click)="performAction($event, action)">{{action.title}}</button>
</ng-template>
</ng-container>
<ng-template #submenuDropdown>
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right" (mouseover)="preventClick($event); openSubmenu(action.title, subMenuHover)" (mouseleave)="preventClick($event)">
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right" (mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (mouseleave)="preventEvent($event)">
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{action.title}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>

View File

@ -34,13 +34,13 @@ export class CardActionablesComponent implements OnInit {
});
}
preventClick(event: any) {
preventEvent(event: any) {
event.stopPropagation();
event.preventDefault();
}
performAction(event: any, action: ActionItem<any>) {
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<any>, dynamicItem: any) {
action._extra = dynamicItem;
this.performAction(event, action);
}
}

View File

@ -280,12 +280,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
performAction(action: ActionItem<any>) {
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);
}
}

View File

@ -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);
}
}
}

View File

@ -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: 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: Series) {
switch (action.action) {
case(Action.MarkAsRead):
this.markAsRead(series);
break;

View File

@ -59,8 +59,8 @@ export class AllCollectionsComponent implements OnInit {
});
}
handleCollectionActionCallback(action: Action, collectionTag: CollectionTag) {
switch (action) {
handleCollectionActionCallback(action: ActionItem<CollectionTag>, collectionTag: CollectionTag) {
switch (action.action) {
case(Action.Edit):
const modalRef = this.modalService.open(EditCollectionTagsComponent, { size: 'lg', scrollable: true });
modalRef.componentInstance.tag = collectionTag;

View File

@ -58,11 +58,11 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten
private onDestory: Subject<void> = new Subject<void>();
bulkActionCallback = (action: Action, data: any) => {
bulkActionCallback = (action: ActionItem<any>, 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: 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<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, this.collectionTag);
action.callback(action, this.collectionTag);
}
}

View File

@ -0,0 +1 @@
<p>devices works!</p>

View File

@ -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 {
}
}

View File

@ -52,11 +52,11 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
active = this.tabs[0];
bulkActionCallback = (action: Action, data: any) => {
bulkActionCallback = (action: ActionItem<any>, 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: Library) {
let lib: Partial<Library> = 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<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, undefined);
action.callback(action, undefined);
}
}

View File

@ -109,7 +109,7 @@ export class ReadingListDetailComponent implements OnInit {
performAction(action: ActionItem<any>) {
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: ReadingList) {
switch(action.action) {
case Action.Delete:
this.deleteList(readingList);
break;

View File

@ -44,14 +44,14 @@ export class ReadingListsComponent implements OnInit {
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));
}
performAction(action: ActionItem<any>, readingList: ReadingList) {
performAction(action: ActionItem<ReadingList>, 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: ReadingList) {
switch(action.action) {
case Action.Delete:
this.readingListService.delete(readingList.id).subscribe(() => {
this.toastr.success('Reading list deleted');

View File

@ -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<any>, 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: 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: 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: 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<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, this.series);
action.callback(action, this.series);
}
}

View File

@ -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: 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: Library) {
if (typeof action.callback === 'function') {
action.callback(action.action, library);
action.callback(action, library);
}
}

View File

@ -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 + '';
}
}
}

View File

@ -0,0 +1,45 @@
<div class="card">
<form [formGroup]="settingsForm" class="card-body">
<div class="row g-0 mb-2">
<div class="col-md-3 col-sm-12 pe-2">
<label for="settings-name" class="form-label">Device Name</label>
<input id="settings-name" class="form-control" formControlName="name" type="text">
<ng-container *ngIf="settingsForm.get('name')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
<div class="col-md-3 col-sm-12 pe-2">
<label for="email" class="form-label">Email</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailTooltip" role="button" tabindex="0"></i>
<ng-template #emailTooltip>This email will be used to accept the file via Send To</ng-template>
<span class="visually-hidden" id="email-help">The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</span>
<input id="email" aria-describedby="email-help" class="form-control" formControlName="email" type="email" placeholder="@kindle.com">
<ng-container *ngIf="settingsForm.get('email')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.email">
This must be a valid email
</p>
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
<div class="col-md-3 col-sm-12 pe-2">
<label for="device-platform" class="form-label">Device Platform</label>
<select id="device-platform" aria-describedby="device-platform-help" class="form-select" formControlName="platform">
<option *ngFor="let patform of devicePlatforms" [value]="patform">{{patform | devicePlatform}}</option>
</select>
<ng-container *ngIf="settingsForm.get('platform')?.errors as errors">
<p class="invalid-feedback" *ngIf="errors.required">
This field is required
</p>
</ng-container>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
<button type="submit" class="flex-fill btn btn-primary" (click)="addDevice()" [disabled]="!settingsForm.dirty">Save</button>
</div>
</form>
</div>

View File

@ -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<void> = new EventEmitter();
@Output() deviceUpdated: EventEmitter<Device> = new EventEmitter();
settingsForm: FormGroup = new FormGroup({});
devicePlatforms = devicePlatforms;
private readonly onDestroy = new Subject<void>();
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();
});
}
}

View File

@ -0,0 +1,39 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>Device Manager</h3></div>
<div class="col-4">
<button class="btn btn-primary float-end" (click)="collapse.toggle()" [attr.aria-expanded]="!addDeviceIsCollapsed"
aria-controls="collapseExample">
<i class="fa fa-plus" aria-hidden="true"></i>&nbsp;Add
</button>
</div>
</div>
<p>
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.
</p>
<div #collapse="ngbCollapse" [(ngbCollapse)]="addDeviceIsCollapsed">
<app-edit-device [device]="device" (deviceAdded)="loadDevices()" (deviceUpdated)="loadDevices()"></app-edit-device>
</div>
<div class="row g-0 mt-2">
<h4>Devices</h4>
<p *ngIf="devices.length == 0">
There are no devices setup yet
</p>
<ng-container *ngFor="let device of devices">
<div class="card col-auto me-3 mb-3" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">{{device.name | sentenceCase}}</h5>
Platform: <h6 class="card-subtitle mb-2 text-muted">{{device.platform | devicePlatform}}</h6>
Email: <h6 class="card-subtitle mb-2 text-muted">{{device.emailAddress}}</h6>
<button class="btn btn-danger me-2" (click)="deleteDevice(device)">Delete</button>
<button class="btn btn-primary" (click)="editDevice(device)">Edit</button>
</div>
</div>
</ng-container>
</div>
</div>

View File

@ -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<Device> = [];
addDeviceIsCollapsed: boolean = true;
device: Device | undefined;
private readonly onDestroy = new Subject<void>();
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();
}
}

View File

@ -335,6 +335,9 @@
<ng-container *ngIf="tab.fragment === 'theme'">
<app-theme-manager></app-theme-manager>
</ng-container>
<ng-container *ngIf="tab.fragment === 'devices'">
<app-manage-devices></app-manage-devices>
</ng-container>
</ng-template>
</li>
</ul>

View File

@ -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;

View File

@ -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

View File

@ -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<void> = new Subject<void>();
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
bulkActionCallback = (action: Action, data: any) => {
bulkActionCallback = (action: ActionItem<any>, 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();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B