mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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:
parent
ab0f13ef74
commit
9d7476a367
@ -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
118
API.Tests/BasicTest.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
79
API.Tests/Services/DeviceServiceTests.cs
Normal file
79
API.Tests/Services/DeviceServiceTests.cs
Normal 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);
|
||||
|
||||
}
|
||||
}
|
92
API/Controllers/DeviceController.cs
Normal file
92
API/Controllers/DeviceController.cs
Normal 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");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
20
API/DTOs/Device/CreateDeviceDto.cs
Normal file
20
API/DTOs/Device/CreateDeviceDto.cs
Normal 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; }
|
||||
|
||||
|
||||
}
|
33
API/DTOs/Device/DeviceDto.cs
Normal file
33
API/DTOs/Device/DeviceDto.cs
Normal 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; }
|
||||
}
|
7
API/DTOs/Device/SendToDeviceDto.cs
Normal file
7
API/DTOs/Device/SendToDeviceDto.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace API.DTOs.Device;
|
||||
|
||||
public class SendToDeviceDto
|
||||
{
|
||||
public int DeviceId { get; set; }
|
||||
public int ChapterId { get; set; }
|
||||
}
|
19
API/DTOs/Device/UpdateDeviceDto.cs
Normal file
19
API/DTOs/Device/UpdateDeviceDto.cs
Normal 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; }
|
||||
}
|
9
API/DTOs/Email/SendToDto.cs
Normal file
9
API/DTOs/Email/SendToDto.cs
Normal 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; }
|
||||
}
|
@ -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)
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
1658
API/Data/Migrations/20220921023455_DeviceSupport.Designer.cs
generated
Normal file
1658
API/Data/Migrations/20220921023455_DeviceSupport.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
API/Data/Migrations/20220921023455_DeviceSupport.cs
Normal file
73
API/Data/Migrations/20220921023455_DeviceSupport.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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");
|
||||
|
50
API/Data/Repositories/DeviceRepository.cs
Normal file
50
API/Data/Repositories/DeviceRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -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
49
API/Entities/Device.cs
Normal 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; }
|
||||
}
|
25
API/Entities/Enums/Device/DevicePlatform.cs
Normal file
25
API/Entities/Enums/Device/DevicePlatform.cs
Normal 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,
|
||||
|
||||
}
|
@ -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; }
|
||||
}
|
||||
|
@ -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>();
|
||||
|
@ -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>();
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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 &&
|
||||
|
@ -117,7 +117,7 @@ public class Program
|
||||
Log.Fatal(ex, "Host terminated unexpectedly");
|
||||
} finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
await Log.CloseAndFlushAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
123
API/Services/DeviceService.cs
Normal file
123
API/Services/DeviceService.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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];
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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)
|
||||
|
@ -53,6 +53,7 @@ public class Startup
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddApplicationServices(_config, _env);
|
||||
|
||||
services.AddControllers(options =>
|
||||
{
|
||||
options.CacheProfiles.Add("Images",
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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>
|
||||
|
8
UI/Web/src/app/_models/device/device-platform.ts
Normal file
8
UI/Web/src/app/_models/device/device-platform.ts
Normal 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];
|
9
UI/Web/src/app/_models/device/device.ts
Normal file
9
UI/Web/src/app/_models/device/device.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { DevicePlatform } from "./device-platform";
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
name: string;
|
||||
platform: DevicePlatform;
|
||||
emailAddress: string;
|
||||
lastUsed: string;
|
||||
}
|
@ -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;
|
||||
|
@ -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 };
|
||||
});
|
||||
|
48
UI/Web/src/app/_services/device.service.ts
Normal file
48
UI/Web/src/app/_services/device.service.ts
Normal 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'});
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -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> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
|
||||
|
@ -68,7 +68,7 @@
|
||||
<label for="logging-level-port" class="form-label">Logging Level</label> <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>
|
||||
|
@ -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();
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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];
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
1
UI/Web/src/app/devices/devices.component.html
Normal file
1
UI/Web/src/app/devices/devices.component.html
Normal file
@ -0,0 +1 @@
|
||||
<p>devices works!</p>
|
0
UI/Web/src/app/devices/devices.component.scss
Normal file
0
UI/Web/src/app/devices/devices.component.scss
Normal file
15
UI/Web/src/app/devices/devices.component.ts
Normal file
15
UI/Web/src/app/devices/devices.component.ts
Normal 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 {
|
||||
}
|
||||
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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');
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
19
UI/Web/src/app/user-settings/_pipes/device-platform.pipe.ts
Normal file
19
UI/Web/src/app/user-settings/_pipes/device-platform.pipe.ts
Normal 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 + '';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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> <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>
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -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> 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>
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
||||
|
@ -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();
|
||||
|
BIN
UI/Web/src/assets/images/sendto/kindle-icon.png
Normal file
BIN
UI/Web/src/assets/images/sendto/kindle-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
BIN
UI/Web/src/assets/images/sendto/pocketbook-icon.png
Normal file
BIN
UI/Web/src/assets/images/sendto/pocketbook-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 468 B |
Loading…
x
Reference in New Issue
Block a user