mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Sort by Average Rating and Big Want to Read fix (#2672)
This commit is contained in:
parent
03e7d38482
commit
1fd72ada36
3
.gitignore
vendored
3
.gitignore
vendored
@ -527,8 +527,7 @@ API/config/stats/*
|
||||
API/config/stats/app_stats.json
|
||||
API/config/pre-metadata/
|
||||
API/config/post-metadata/
|
||||
API/config/relations-imported.csv
|
||||
API/config/relations.csv
|
||||
API/config/*.csv
|
||||
API.Tests/TestResults/
|
||||
UI/Web/.vscode/settings.json
|
||||
/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/*
|
||||
|
@ -488,15 +488,21 @@ public class CleanupServiceTests : AbstractDbTest
|
||||
var user = new AppUser()
|
||||
{
|
||||
UserName = "CleanupWantToRead_ShouldRemoveFullyReadSeries",
|
||||
WantToRead = new List<Series>()
|
||||
{
|
||||
s
|
||||
}
|
||||
};
|
||||
_context.AppUser.Add(user);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
// Add want to read
|
||||
user.WantToRead = new List<AppUserWantToRead>()
|
||||
{
|
||||
new AppUserWantToRead()
|
||||
{
|
||||
SeriesId = s.Id
|
||||
}
|
||||
};
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
await _readerService.MarkSeriesAsRead(user, s.Id);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
|
@ -53,6 +53,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="30.1.0" />
|
||||
<PackageReference Include="MailKit" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
@ -1250,7 +1250,7 @@ public class OpdsController : BaseApiController
|
||||
if (progress != null)
|
||||
{
|
||||
link.LastRead = progress.PageNum;
|
||||
link.LastReadDate = progress.LastModifiedUtc.ToString("o"); // Adhere to ISO 8601
|
||||
link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601
|
||||
}
|
||||
link.IsPageStream = true;
|
||||
return link;
|
||||
|
@ -38,18 +38,16 @@ public class ServerController : BaseApiController
|
||||
private readonly IStatsService _statsService;
|
||||
private readonly ICleanupService _cleanupService;
|
||||
private readonly IScannerService _scannerService;
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEasyCachingProviderFactory _cachingProviderFactory;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
public ServerController(ILogger<ServerController> logger,
|
||||
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
|
||||
ICleanupService cleanupService, IScannerService scannerService, IAccountService accountService,
|
||||
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService,
|
||||
IStatsService statsService, ICleanupService cleanupService, IScannerService scannerService,
|
||||
ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEasyCachingProviderFactory cachingProviderFactory,
|
||||
ILocalizationService localizationService, IEmailService emailService)
|
||||
ILocalizationService localizationService)
|
||||
{
|
||||
_logger = logger;
|
||||
_backupService = backupService;
|
||||
@ -58,12 +56,10 @@ public class ServerController : BaseApiController
|
||||
_statsService = statsService;
|
||||
_cleanupService = cleanupService;
|
||||
_scannerService = scannerService;
|
||||
_accountService = accountService;
|
||||
_taskScheduler = taskScheduler;
|
||||
_unitOfWork = unitOfWork;
|
||||
_cachingProviderFactory = cachingProviderFactory;
|
||||
_localizationService = localizationService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -180,11 +176,22 @@ public class ServerController : BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for updates and pushes an event to the UI
|
||||
/// </summary>
|
||||
/// <remarks>Some users have websocket issues so this is not always reliable to alert the user</remarks>
|
||||
[HttpGet("check-for-updates")]
|
||||
public async Task<ActionResult> CheckForAnnouncements()
|
||||
{
|
||||
await _taskScheduler.CheckForUpdate();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for updates, if no updates that are > current version installed, returns null
|
||||
/// </summary>
|
||||
[HttpGet("check-update")]
|
||||
public async Task<ActionResult<UpdateNotificationDto>> CheckForUpdates()
|
||||
public async Task<ActionResult<UpdateNotificationDto?>> CheckForUpdates()
|
||||
{
|
||||
return Ok(await _versionUpdaterService.CheckForUpdate());
|
||||
}
|
||||
@ -268,14 +275,4 @@ public class ServerController : BaseApiController
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for updates and pushes an event to the UI
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("check-for-updates")]
|
||||
public async Task<ActionResult> CheckForAnnouncements()
|
||||
{
|
||||
await _taskScheduler.CheckForUpdate();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.WantToRead;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
@ -91,15 +92,15 @@ public class WantToReadController : BaseApiController
|
||||
AppUserIncludes.WantToRead);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
var existingIds = user.WantToRead.Select(s => s.Id).ToList();
|
||||
existingIds.AddRange(dto.SeriesIds);
|
||||
var existingIds = user.WantToRead.Select(s => s.SeriesId).ToList();
|
||||
var idsToAdd = dto.SeriesIds.Except(existingIds);
|
||||
|
||||
var idsToAdd = existingIds.Distinct().ToList();
|
||||
|
||||
var seriesToAdd = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(idsToAdd);
|
||||
foreach (var series in seriesToAdd)
|
||||
foreach (var id in idsToAdd)
|
||||
{
|
||||
user.WantToRead.Add(series);
|
||||
user.WantToRead.Add(new AppUserWantToRead()
|
||||
{
|
||||
SeriesId = id
|
||||
});
|
||||
}
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return Ok();
|
||||
@ -127,7 +128,9 @@ public class WantToReadController : BaseApiController
|
||||
AppUserIncludes.WantToRead);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
user.WantToRead = user.WantToRead.Where(s => !dto.SeriesIds.Contains(s.Id)).ToList();
|
||||
user.WantToRead = user.WantToRead
|
||||
.Where(s => !dto.SeriesIds.Contains(s.SeriesId))
|
||||
.ToList();
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return Ok();
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
|
@ -30,4 +30,8 @@ public enum SortField
|
||||
/// Last time the user had any reading progress
|
||||
/// </summary>
|
||||
ReadProgress = 7,
|
||||
/// <summary>
|
||||
/// Kavita+ Only - External Average Rating
|
||||
/// </summary>
|
||||
AverageRating = 8
|
||||
}
|
||||
|
@ -62,6 +62,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
public DbSet<ExternalRating> ExternalRating { get; set; } = null!;
|
||||
public DbSet<ExternalSeriesMetadata> ExternalSeriesMetadata { get; set; } = null!;
|
||||
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
|
||||
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
|
86
API/Data/ManualMigrations/MigrateManualHistory.cs
Normal file
86
API/Data/ManualMigrations/MigrateManualHistory.cs
Normal file
@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Data.ManualMigrations;
|
||||
|
||||
/// <summary>
|
||||
/// Introduced in v0.7.14, will store history so that going forward, migrations can just check against the history
|
||||
/// and I don't need to remove old migrations
|
||||
/// </summary>
|
||||
public static class MigrateManualHistory
|
||||
{
|
||||
public static async Task Migrate(DataContext dataContext, ILogger<Program> logger)
|
||||
{
|
||||
logger.LogCritical(
|
||||
"Running MigrateManualHistory migration - Please be patient, this may take some time. This is not an error");
|
||||
|
||||
if (await dataContext.ManualMigrationHistory.AnyAsync())
|
||||
{
|
||||
logger.LogCritical(
|
||||
"Running MigrateManualHistory migration - Completed. This is not an error");
|
||||
return;
|
||||
}
|
||||
|
||||
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "MigrateUserLibrarySideNavStream",
|
||||
ProductVersion = "0.7.9.0",
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "MigrateSmartFilterEncoding",
|
||||
ProductVersion = "0.7.11.0",
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "MigrateLibrariesToHaveAllFileTypes",
|
||||
ProductVersion = "0.7.11.0",
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "MigrateEmailTemplates",
|
||||
ProductVersion = "0.7.14.0",
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "MigrateVolumeNumber",
|
||||
ProductVersion = "0.7.14.0",
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "MigrateWantToReadExport",
|
||||
ProductVersion = "0.7.14.0",
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "MigrateWantToReadImport",
|
||||
ProductVersion = "0.7.14.0",
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "MigrateManualHistory",
|
||||
ProductVersion = "0.7.14.0",
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await dataContext.SaveChangesAsync();
|
||||
|
||||
logger.LogCritical(
|
||||
"Running MigrateManualHistory migration - Completed. This is not an error");
|
||||
}
|
||||
}
|
81
API/Data/ManualMigrations/MigrateWantToReadExport.cs
Normal file
81
API/Data/ManualMigrations/MigrateWantToReadExport.cs
Normal file
@ -0,0 +1,81 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using API.Services;
|
||||
using CsvHelper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Data.ManualMigrations;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// v0.7.13.12/v0.7.14 - Want to read is extracted and saved in a csv
|
||||
/// </summary>
|
||||
/// <remarks>This must run BEFORE any DB migrations</remarks>
|
||||
public static class MigrateWantToReadExport
|
||||
{
|
||||
public static async Task Migrate(DataContext dataContext, IDirectoryService directoryService, ILogger<Program> logger)
|
||||
{
|
||||
logger.LogCritical(
|
||||
"Running MigrateWantToReadExport migration - Please be patient, this may take some time. This is not an error");
|
||||
|
||||
var columnExists = false;
|
||||
await using var command = dataContext.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = "PRAGMA table_info('Series')";
|
||||
|
||||
await dataContext.Database.OpenConnectionAsync();
|
||||
await using var result = await command.ExecuteReaderAsync();
|
||||
while (await result.ReadAsync())
|
||||
{
|
||||
var columnName = result["name"].ToString();
|
||||
if (columnName != "AppUserId") continue;
|
||||
|
||||
logger.LogInformation("Column 'AppUserId' exists in the 'Series' table. Running migration...");
|
||||
// Your migration logic here
|
||||
columnExists = true;
|
||||
break;
|
||||
}
|
||||
|
||||
await result.CloseAsync();
|
||||
|
||||
if (!columnExists)
|
||||
{
|
||||
logger.LogCritical(
|
||||
"Running MigrateWantToReadExport migration - Completed. This is not an error");
|
||||
return;
|
||||
}
|
||||
|
||||
await using var command2 = dataContext.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = "Select AppUserId, Id from Series WHERE AppUserId IS NOT NULL ORDER BY AppUserId;";
|
||||
|
||||
await dataContext.Database.OpenConnectionAsync();
|
||||
await using var result2 = await command.ExecuteReaderAsync();
|
||||
|
||||
await using var writer = new StreamWriter(Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv"));
|
||||
await using var csvWriter = new CsvWriter(writer, CultureInfo.InvariantCulture);
|
||||
|
||||
// Write header
|
||||
csvWriter.WriteField("AppUserId");
|
||||
csvWriter.WriteField("Id");
|
||||
await csvWriter.NextRecordAsync();
|
||||
|
||||
// Write data
|
||||
while (await result2.ReadAsync())
|
||||
{
|
||||
var appUserId = result2["AppUserId"].ToString();
|
||||
var id = result2["Id"].ToString();
|
||||
|
||||
csvWriter.WriteField(appUserId);
|
||||
csvWriter.WriteField(id);
|
||||
await csvWriter.NextRecordAsync();
|
||||
}
|
||||
|
||||
|
||||
await result2.CloseAsync();
|
||||
writer.Close();
|
||||
|
||||
logger.LogCritical(
|
||||
"Running MigrateWantToReadExport migration - Completed. This is not an error");
|
||||
}
|
||||
}
|
60
API/Data/ManualMigrations/MigrateWantToReadImport.cs
Normal file
60
API/Data/ManualMigrations/MigrateWantToReadImport.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Services;
|
||||
using CsvHelper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Data.ManualMigrations;
|
||||
|
||||
/// <summary>
|
||||
/// v0.7.13.12/v0.7.14 - Want to read is imported from a csv
|
||||
/// </summary>
|
||||
public static class MigrateWantToReadImport
|
||||
{
|
||||
public static async Task Migrate(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger<Program> logger)
|
||||
{
|
||||
var importFile = Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv");
|
||||
var outputFile = Path.Join(directoryService.ConfigDirectory, "imported-want-to-read-migration.csv");
|
||||
|
||||
logger.LogCritical(
|
||||
"Running MigrateWantToReadImport migration - Please be patient, this may take some time. This is not an error");
|
||||
|
||||
if (!File.Exists(importFile) || File.Exists(outputFile))
|
||||
{
|
||||
logger.LogCritical(
|
||||
"Running MigrateWantToReadImport migration - Completed. This is not an error");
|
||||
return;
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(importFile);
|
||||
using var csvReader = new CsvReader(reader, CultureInfo.InvariantCulture);
|
||||
// Read the records from the CSV file
|
||||
await csvReader.ReadAsync();
|
||||
csvReader.ReadHeader(); // Skip the header row
|
||||
|
||||
while (await csvReader.ReadAsync())
|
||||
{
|
||||
// Read the values of AppUserId and Id columns
|
||||
var appUserId = csvReader.GetField<int>("AppUserId");
|
||||
var seriesId = csvReader.GetField<int>("Id");
|
||||
var user = await unitOfWork.UserRepository.GetUserByIdAsync(appUserId, AppUserIncludes.WantToRead);
|
||||
if (user == null || user.WantToRead.Any(w => w.SeriesId == seriesId)) continue;
|
||||
|
||||
user.WantToRead.Add(new AppUserWantToRead()
|
||||
{
|
||||
SeriesId = seriesId
|
||||
});
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
reader.Close();
|
||||
|
||||
File.WriteAllLines(outputFile, await File.ReadAllLinesAsync(importFile));
|
||||
logger.LogCritical(
|
||||
"Running MigrateWantToReadImport migration - Completed. This is not an error");
|
||||
}
|
||||
}
|
2844
API/Data/Migrations/20240130190617_WantToReadFix.Designer.cs
generated
Normal file
2844
API/Data/Migrations/20240130190617_WantToReadFix.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
106
API/Data/Migrations/20240130190617_WantToReadFix.cs
Normal file
106
API/Data/Migrations/20240130190617_WantToReadFix.cs
Normal file
@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class WantToReadFix : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Series_AspNetUsers_AppUserId",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Series_AppUserId",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AppUserId",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUserWantToRead",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppUserWantToRead", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserWantToRead_AspNetUsers_AppUserId",
|
||||
column: x => x.AppUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserWantToRead_Series_SeriesId",
|
||||
column: x => x.SeriesId,
|
||||
principalTable: "Series",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ManualMigrationHistory",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ProductVersion = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
RanAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ManualMigrationHistory", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserWantToRead_AppUserId",
|
||||
table: "AppUserWantToRead",
|
||||
column: "AppUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserWantToRead_SeriesId",
|
||||
table: "AppUserWantToRead",
|
||||
column: "SeriesId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppUserWantToRead");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ManualMigrationHistory");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AppUserId",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Series_AppUserId",
|
||||
table: "Series",
|
||||
column: "AppUserId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Series_AspNetUsers_AppUserId",
|
||||
table: "Series",
|
||||
column: "AppUserId",
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
@ -602,6 +602,27 @@ namespace API.Data.Migrations
|
||||
b.ToTable("AppUserTableOfContent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserWantToRead");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Chapter", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -980,6 +1001,26 @@ namespace API.Data.Migrations
|
||||
b.ToTable("MangaFile");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ManualMigrationHistory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProductVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("RanAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ManualMigrationHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MediaError", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -1551,9 +1592,6 @@ namespace API.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AvgHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -1634,8 +1672,6 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("Series");
|
||||
@ -2252,6 +2288,25 @@ namespace API.Data.Migrations
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
.WithMany("WantToRead")
|
||||
.HasForeignKey("AppUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("API.Entities.Series", "Series")
|
||||
.WithMany()
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AppUser");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Chapter", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.Volume", "Volume")
|
||||
@ -2479,10 +2534,6 @@ namespace API.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("API.Entities.Series", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", null)
|
||||
.WithMany("WantToRead")
|
||||
.HasForeignKey("AppUserId");
|
||||
|
||||
b.HasOne("API.Entities.Library", "Library")
|
||||
.WithMany("Series")
|
||||
.HasForeignKey("LibraryId")
|
||||
|
@ -363,8 +363,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
|
||||
.IsRestricted(QueryContext.Search)
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(l => l.Name.ToLower())
|
||||
.Take(maxRecords)
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -383,8 +383,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Include(s => s.Library)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(s => s.SortName!.ToLower())
|
||||
.Take(maxRecords)
|
||||
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
|
||||
.AsEnumerable();
|
||||
|
||||
@ -420,8 +420,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(r => r.NormalizedTitle)
|
||||
.Take(maxRecords)
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -431,7 +431,6 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Where(c => c.Promoted || isAdmin)
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.OrderBy(s => s.NormalizedTitle)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
@ -443,8 +442,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
.SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
|
||||
.AsSplitQuery()
|
||||
.Distinct()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(p => p.NormalizedName)
|
||||
.Take(maxRecords)
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -453,8 +452,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
|
||||
.AsSplitQuery()
|
||||
.Distinct()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(t => t.NormalizedTitle)
|
||||
.Take(maxRecords)
|
||||
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -463,8 +462,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
|
||||
.AsSplitQuery()
|
||||
.Distinct()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(t => t.NormalizedTitle)
|
||||
.Take(maxRecords)
|
||||
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -482,8 +481,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
result.Files = await _context.MangaFile
|
||||
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(f => f.FilePath)
|
||||
.Take(maxRecords)
|
||||
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
@ -499,8 +498,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
)
|
||||
.Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(c => c.TitleName)
|
||||
.Take(maxRecords)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -991,6 +990,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
SortField.TimeToRead => query.DoOrderBy(s => s.AvgHoursToRead, filter.SortOptions),
|
||||
SortField.ReleaseYear => query.DoOrderBy(s => s.Metadata.ReleaseYear, filter.SortOptions),
|
||||
SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id).Select(p => p.LastModified).Max(), filter.SortOptions),
|
||||
SortField.AverageRating => query.DoOrderBy(s => s.ExternalSeriesMetadata.ExternalRatings
|
||||
.Where(p => p.SeriesId == s.Id).Average(p => p.AverageScore), filter.SortOptions),
|
||||
_ => query
|
||||
};
|
||||
|
||||
@ -1043,7 +1044,9 @@ public class SeriesRepository : ISeriesRepository
|
||||
var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead);
|
||||
if (wantToReadStmt == null) return query;
|
||||
|
||||
var seriesIds = _context.AppUser.Where(u => u.Id == userId).SelectMany(u => u.WantToRead).Select(s => s.Id);
|
||||
var seriesIds = _context.AppUser.Where(u => u.Id == userId)
|
||||
.SelectMany(u => u.WantToRead)
|
||||
.Select(s => s.SeriesId);
|
||||
if (bool.Parse(wantToReadStmt.Value))
|
||||
{
|
||||
query = query.Where(s => seriesIds.Contains(s.Id));
|
||||
@ -1869,7 +1872,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
var query = _context.AppUser
|
||||
.Where(user => user.Id == userId)
|
||||
.SelectMany(u => u.WantToRead)
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Where(s => libraryIds.Contains(s.Series.LibraryId))
|
||||
.Select(w => w.Series)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking();
|
||||
|
||||
@ -1884,7 +1888,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
var query = _context.AppUser
|
||||
.Where(user => user.Id == userId)
|
||||
.SelectMany(u => u.WantToRead)
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Where(s => libraryIds.Contains(s.Series.LibraryId))
|
||||
.Select(w => w.Series)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking();
|
||||
|
||||
@ -1899,7 +1904,8 @@ public class SeriesRepository : ISeriesRepository
|
||||
return await _context.AppUser
|
||||
.Where(user => user.Id == userId)
|
||||
.SelectMany(u => u.WantToRead)
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Where(s => libraryIds.Contains(s.Series.LibraryId))
|
||||
.Select(w => w.Series)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
@ -1994,7 +2000,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
||||
return await _context.AppUser
|
||||
.Where(user => user.Id == userId)
|
||||
.SelectMany(u => u.WantToRead.Where(s => s.Id == seriesId && libraryIds.Contains(s.LibraryId)))
|
||||
.SelectMany(u => u.WantToRead.Where(s => s.SeriesId == seriesId && libraryIds.Contains(s.Series.LibraryId)))
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
.AnyAsync();
|
||||
|
@ -31,7 +31,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||
/// <summary>
|
||||
/// A list of Series the user want's to read
|
||||
/// </summary>
|
||||
public ICollection<Series> WantToRead { get; set; } = null!;
|
||||
public ICollection<AppUserWantToRead> WantToRead { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// A list of Devices which allows the user to send files to
|
||||
/// </summary>
|
||||
|
20
API/Entities/AppUserWantToRead.cs
Normal file
20
API/Entities/AppUserWantToRead.cs
Normal file
@ -0,0 +1,20 @@
|
||||
namespace API.Entities;
|
||||
|
||||
public class AppUserWantToRead
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public required int SeriesId { get; set; }
|
||||
public virtual Series Series { get; set; }
|
||||
|
||||
|
||||
// Relationships
|
||||
/// <summary>
|
||||
/// Navigational Property for EF. Links to a unique AppUser
|
||||
/// </summary>
|
||||
public AppUser AppUser { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// User this table of content belongs to
|
||||
/// </summary>
|
||||
public int AppUserId { get; set; }
|
||||
}
|
14
API/Entities/ManualMigrationHistory.cs
Normal file
14
API/Entities/ManualMigrationHistory.cs
Normal file
@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// This will track manual migrations so that I can use simple selects to check if a Manual Migration is needed
|
||||
/// </summary>
|
||||
public class ManualMigrationHistory
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string ProductVersion { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public DateTime RanAt { get; set; }
|
||||
}
|
@ -37,6 +37,8 @@ public static class BookmarkSort
|
||||
SortField.TimeToRead => query.DoOrderBy(s => s.Series.AvgHoursToRead, sortOptions),
|
||||
SortField.ReleaseYear => query.DoOrderBy(s => s.Series.Metadata.ReleaseYear, sortOptions),
|
||||
SortField.ReadProgress => query.DoOrderBy(s => s.Series.Progress.Where(p => p.SeriesId == s.Series.Id).Select(p => p.LastModified).Max(), sortOptions),
|
||||
SortField.AverageRating => query.DoOrderBy(s => s.Series.ExternalSeriesMetadata.ExternalRatings
|
||||
.Where(p => p.SeriesId == s.Series.Id).Average(p => p.AverageScore), sortOptions),
|
||||
_ => query
|
||||
};
|
||||
|
||||
|
@ -33,6 +33,8 @@ public static class SeriesSort
|
||||
SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id && p.AppUserId == userId)
|
||||
.Select(p => p.LastModified)
|
||||
.Max(), sortOptions),
|
||||
SortField.AverageRating => query.DoOrderBy(s => s.ExternalSeriesMetadata.ExternalRatings
|
||||
.Where(p => p.SeriesId == s.Id).Average(p => p.AverageScore), sortOptions),
|
||||
_ => query
|
||||
};
|
||||
|
||||
|
@ -73,8 +73,10 @@ public static class LogLevelOptions
|
||||
|
||||
if (isRequestLoggingMiddleware)
|
||||
{
|
||||
if (e.Properties.ContainsKey("Path") && e.Properties["Path"].ToString().Replace("\"", string.Empty) == "/api/health") return false;
|
||||
if (e.Properties.ContainsKey("Path") && e.Properties["Path"].ToString().Replace("\"", string.Empty) == "/hubs/messages") return false;
|
||||
var path = e.Properties["Path"].ToString().Replace("\"", string.Empty);
|
||||
if (e.Properties.ContainsKey("Path") && path == "/api/health") return false;
|
||||
if (e.Properties.ContainsKey("Path") && path == "/hubs/messages") return false;
|
||||
if (e.Properties.ContainsKey("Path") && path.StartsWith("/api/image")) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
@ -87,6 +87,30 @@ public class Program
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Before manual migrations that need to run before actual migrations
|
||||
try
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
// Apply all migrations on startup
|
||||
var dataContext = services.GetRequiredService<DataContext>();
|
||||
var directoryService = services.GetRequiredService<IDirectoryService>();
|
||||
|
||||
logger.LogInformation("Running Migrations");
|
||||
|
||||
// v0.7.14
|
||||
await MigrateWantToReadExport.Migrate(dataContext, directoryService, logger);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
logger.LogInformation("Running Migrations - complete");
|
||||
}).GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogCritical(ex, "An error occurred during migration");
|
||||
}
|
||||
|
||||
await context.Database.MigrateAsync();
|
||||
|
||||
|
||||
|
@ -288,8 +288,8 @@ public class CleanupService : ICleanupService
|
||||
var seriesIds = series.Select(s => s.Id).ToList();
|
||||
if (seriesIds.Count == 0) continue;
|
||||
|
||||
user.WantToRead ??= new List<Series>();
|
||||
user.WantToRead = user.WantToRead.Where(s => !seriesIds.Contains(s.Id)).ToList();
|
||||
user.WantToRead ??= new List<AppUserWantToRead>();
|
||||
user.WantToRead = user.WantToRead.Where(s => !seriesIds.Contains(s.SeriesId)).ToList();
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
}
|
||||
|
||||
|
@ -248,6 +248,8 @@ public class Startup
|
||||
// v0.7.14
|
||||
await MigrateEmailTemplates.Migrate(directoryService, logger);
|
||||
await MigrateVolumeNumber.Migrate(unitOfWork, dataContext, logger);
|
||||
await MigrateWantToReadImport.Migrate(unitOfWork, directoryService, logger);
|
||||
await MigrateManualHistory.Migrate(dataContext, logger);
|
||||
|
||||
// Update the version in the DB after all migrations are run
|
||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||
|
@ -14,6 +14,12 @@ function generateChecksum(str, algorithm, encoding) {
|
||||
|
||||
const result = {};
|
||||
|
||||
// Remove file if it exists
|
||||
const cacheBustingFilePath = './i18n-cache-busting.json';
|
||||
if (fs.existsSync(cacheBustingFilePath)) {
|
||||
fs.unlinkSync(cacheBustingFilePath);
|
||||
}
|
||||
|
||||
glob.sync(`${jsonFilesDir}**/*.json`).forEach(path => {
|
||||
let tokens = path.split('dist\\browser\\assets\\langs\\');
|
||||
if (tokens.length === 1) {
|
||||
|
@ -3,12 +3,12 @@
|
||||
"version": "0.7.12.1",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "npm run cache-langs && ng serve",
|
||||
"build": "npm run cache-langs && ng build",
|
||||
"start": "npm run cache-locale && ng serve",
|
||||
"build": "npm run cache-locale && ng build",
|
||||
"minify-langs": "node minify-json.js",
|
||||
"cache-langs": "node hash-localization.js",
|
||||
"cache-langs-prime": "node hash-localization-prime.js",
|
||||
"prod": "npm run cache-langs-prime && ng build --configuration production && npm run minify-langs && npm run cache-langs",
|
||||
"cache-locale": "node hash-localization.js",
|
||||
"cache-locale-prime": "node hash-localization-prime.js",
|
||||
"prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale",
|
||||
"explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e"
|
||||
|
@ -21,6 +21,10 @@ export enum SortField {
|
||||
TimeToRead = 5,
|
||||
ReleaseYear = 6,
|
||||
ReadProgress = 7,
|
||||
/**
|
||||
* Kavita+ only
|
||||
*/
|
||||
AverageRating = 8
|
||||
}
|
||||
|
||||
export const allSortFields = Object.keys(SortField)
|
||||
|
@ -27,6 +27,8 @@ export class SortFieldPipe implements PipeTransform {
|
||||
return this.translocoService.translate('sort-field-pipe.release-year');
|
||||
case SortField.ReadProgress:
|
||||
return this.translocoService.translate('sort-field-pipe.read-progress');
|
||||
case SortField.AverageRating:
|
||||
return this.translocoService.translate('sort-field-pipe.average-rating');
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -39,11 +39,16 @@ export class ServerService {
|
||||
}
|
||||
|
||||
checkForUpdate() {
|
||||
return this.http.get<UpdateVersionEvent>(this.baseUrl + 'server/check-update', {});
|
||||
return this.http.get<UpdateVersionEvent | null>(this.baseUrl + 'server/check-update');
|
||||
}
|
||||
|
||||
checkHowOutOfDate() {
|
||||
return this.http.get<string>(this.baseUrl + 'server/checkHowOutOfDate', TextResonse)
|
||||
.pipe(map(r => parseInt(r, 10)));
|
||||
}
|
||||
|
||||
checkForUpdates() {
|
||||
return this.http.get(this.baseUrl + 'server/check-for-updates', {});
|
||||
return this.http.get<UpdateVersionEvent>(this.baseUrl + 'server/check-for-updates', {});
|
||||
}
|
||||
|
||||
getChangelog() {
|
||||
|
@ -40,7 +40,7 @@ export class ManageEmailSettingsComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, []));
|
||||
this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, [Validators.pattern(/^(http:|https:)+[^\s]+[\w]$/)]));
|
||||
|
||||
this.settingsForm.addControl('host', new FormControl(this.serverSettings.smtpConfig.host, []));
|
||||
this.settingsForm.addControl('port', new FormControl(this.serverSettings.smtpConfig.port, []));
|
||||
|
@ -5,6 +5,21 @@
|
||||
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="settings-hostname" class="form-label">{{t('host-name-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #hostNameTooltip>{{t('host-name-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="settings-hostname-help">
|
||||
<ng-container [ngTemplateOutlet]="hostNameTooltip"></ng-container>
|
||||
</span>
|
||||
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
|
||||
[class.is-invalid]="settingsForm.get('hostName')?.invalid && settingsForm.get('hostName')?.touched">
|
||||
<div id="hostname-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
|
||||
<div *ngIf="settingsForm.get('hostName')?.errors?.pattern">
|
||||
{{t('host-name-validation')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="settings-baseurl" class="form-label">{{t('base-url-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #baseUrlTooltip>{{t('base-url-tooltip')}}</ng-template>
|
||||
|
@ -104,7 +104,6 @@ export class ManageSettingsComponent implements OnInit {
|
||||
modelSettings.smtpConfig = this.serverSettings.smtpConfig;
|
||||
|
||||
|
||||
|
||||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
|
@ -21,7 +21,6 @@ export class ChangelogComponent implements OnInit {
|
||||
constructor(private serverService: ServerService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.serverService.getChangelog().subscribe(updates => {
|
||||
this.updates = updates;
|
||||
this.isLoading = false;
|
||||
|
@ -0,0 +1,18 @@
|
||||
<ng-container *transloco="let t; read:'out-of-date-modal'">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<p><strong>{{t('subtitle', {count: versionsOutOfDate})}}</strong></p>
|
||||
<p>{{t('description-1')}}</p>
|
||||
<p [innerHTML]="t('description-2') | safeHtml"></p>
|
||||
<p>{{t('description-3')}}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a class="btn btn-link me-1" href="https://discord.gg/b52wT37kt7" target="_blank" rel="noreferrer noopener">Discord</a>
|
||||
<button type="button" class="btn btn-primary" (click)="close()">{{t('close')}}</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
@ -0,0 +1,38 @@
|
||||
import {Component, DestroyRef, inject, Input} from '@angular/core';
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {AsyncPipe, NgForOf, NgIf} from "@angular/common";
|
||||
import {NgbActiveModal, NgbHighlight, NgbModal, NgbTypeahead} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {ServerService} from "../../../_services/server.service";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {map} from "rxjs/operators";
|
||||
import {ChangelogComponent} from "../changelog/changelog.component";
|
||||
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-out-of-date-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
NgForOf,
|
||||
NgIf,
|
||||
NgbHighlight,
|
||||
NgbTypeahead,
|
||||
TranslocoDirective,
|
||||
AsyncPipe,
|
||||
ChangelogComponent,
|
||||
SafeHtmlPipe
|
||||
],
|
||||
templateUrl: './out-of-date-modal.component.html',
|
||||
styleUrl: './out-of-date-modal.component.scss'
|
||||
})
|
||||
export class OutOfDateModalComponent {
|
||||
|
||||
private readonly ngbModal = inject(NgbActiveModal);
|
||||
|
||||
@Input({required: true}) versionsOutOfDate: number = 0;
|
||||
|
||||
close() {
|
||||
this.ngbModal.close();
|
||||
}
|
||||
}
|
@ -13,6 +13,8 @@ import { SideNavComponent } from './sidenav/_components/side-nav/side-nav.compon
|
||||
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {ServerService} from "./_services/server.service";
|
||||
import {ImportCblModalComponent} from "./reading-list/_modals/import-cbl-modal/import-cbl-modal.component";
|
||||
import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@ -67,15 +69,6 @@ export class AppComponent implements OnInit {
|
||||
|
||||
});
|
||||
|
||||
// Every hour, have the UI check for an update. People seriously stay out of date
|
||||
// interval(60 * 60 * 1000) // 60 minutes in milliseconds
|
||||
// .pipe(
|
||||
// switchMap(() => this.accountService.currentUser$),
|
||||
// filter(u => u !== undefined && this.accountService.hasAdminRole(u)),
|
||||
// switchMap(_ => this.serverService.checkForUpdates())
|
||||
// )
|
||||
// .subscribe();
|
||||
|
||||
|
||||
this.transitionState$ = this.accountService.currentUser$.pipe(
|
||||
tap(user => {
|
||||
@ -111,11 +104,21 @@ export class AppComponent implements OnInit {
|
||||
// On load, make an initial call for valid license
|
||||
this.accountService.hasValidLicense().subscribe();
|
||||
|
||||
interval(4 * 60 * 60 * 1000) // 4 hours in milliseconds
|
||||
// Every hour, have the UI check for an update. People seriously stay out of date
|
||||
interval(2* 60 * 60 * 1000) // 2 hours in milliseconds
|
||||
.pipe(
|
||||
switchMap(() => this.accountService.currentUser$),
|
||||
filter(u => this.accountService.hasAdminRole(u!)),
|
||||
switchMap(_ => this.serverService.checkForUpdates())
|
||||
filter(u => u !== undefined && this.accountService.hasAdminRole(u)),
|
||||
switchMap(_ => this.serverService.checkHowOutOfDate()),
|
||||
filter(versionOutOfDate => {
|
||||
return !isNaN(versionOutOfDate) && versionOutOfDate > 2;
|
||||
}),
|
||||
tap(versionOutOfDate => {
|
||||
if (!this.ngbModal.hasOpenModals()) {
|
||||
const ref = this.ngbModal.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||
ref.componentInstance.versionsOutOfDate = 3;
|
||||
}
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
@ -288,7 +288,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
if (this.activeTabId === TabID.Chapters) chapterArray = this.chapters;
|
||||
|
||||
// We must augment chapter indices as Bulk Selection assumes all on one page, but Storyline has mixed
|
||||
const chapterIndexModifier = this.activeTabId === TabID.Storyline ? this.volumes.length + 1 : 0;
|
||||
const chapterIndexModifier = this.activeTabId === TabID.Storyline ? this.volumes.length : 0;
|
||||
const selectedChapterIds = chapterArray.filter((_chapter, index: number) => {
|
||||
const mappedIndex = index + chapterIndexModifier;
|
||||
return selectedChapterIndexes.includes(mappedIndex + '');
|
||||
|
@ -12,8 +12,9 @@ import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe';
|
||||
import { NgIf, NgFor } from '@angular/common';
|
||||
import { EditDeviceComponent } from '../edit-device/edit-device.component';
|
||||
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
import {SettingsService} from "../../admin/settings.service";
|
||||
import {ConfirmService} from "../../shared/confirm.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-devices',
|
||||
@ -28,6 +29,7 @@ export class ManageDevicesComponent implements OnInit {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly deviceService = inject(DeviceService);
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
|
||||
devices: Array<Device> = [];
|
||||
addDeviceIsCollapsed: boolean = true;
|
||||
@ -53,7 +55,8 @@ export class ManageDevicesComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
deleteDevice(device: Device) {
|
||||
async deleteDevice(device: Device) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.delete-device'))) return;
|
||||
this.deviceService.deleteDevice(device.id).subscribe(() => {
|
||||
const index = this.devices.indexOf(device);
|
||||
this.devices.splice(index, 1);
|
||||
|
@ -156,7 +156,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
|
||||
if (res) {
|
||||
this.tabs.push({title: 'scrobbling-tab', fragment: FragmentID.Scrobbling});
|
||||
if (this.tabs.filter(t => t.fragment == FragmentID.Scrobbling).length === 0) {
|
||||
this.tabs.push({title: 'scrobbling-tab', fragment: FragmentID.Scrobbling});
|
||||
}
|
||||
|
||||
this.hasActiveLicense = true;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
@ -546,6 +546,15 @@
|
||||
"title": "Announcements"
|
||||
},
|
||||
|
||||
"out-of-date-modal": {
|
||||
"title": "Don't fall behind!",
|
||||
"close": "{{common.close}}",
|
||||
"subtitle": "It seems your install is more than {{count}} versions behind!",
|
||||
"description-1": "Please consider upgrading so that you're running the latest version of Kavita.",
|
||||
"description-2": "Take a look at our <a href='https://wiki.kavitareader.com/guides/updating/' target='_blank' rel='noreferrer noopener'>wiki</a> for instructions on how to update.",
|
||||
"description-3": "If there is a specific reason you haven't updated yet we would love to know what is keeping you on an outdated version! Stop by our discord and let us know what is blocking your upgrade path."
|
||||
},
|
||||
|
||||
"changelog": {
|
||||
"installed": "Installed",
|
||||
"download": "Download",
|
||||
@ -1190,6 +1199,9 @@
|
||||
"folder-watching-label": "Folder Watching",
|
||||
"folder-watching-tooltip": "Allows Kavita to monitor Library Folders to detect changes and invoke scanning on those changes. This allows content to be updated without manually invoking scans or waiting for nightly scans. Will always wait 10 minutes before triggering scan.",
|
||||
"enable-folder-watching": "Enable Folder Watching",
|
||||
"host-name-label": "{{manage-email-settings.host-name-label}}",
|
||||
"host-name-tooltip": "{{manage-email-settings.host-name-tooltip}}",
|
||||
"host-name-validation": "{{manage-email-settings.host-name-validation}}",
|
||||
|
||||
|
||||
"reset-to-default": "{{common.reset-to-default}}",
|
||||
@ -1643,7 +1655,8 @@
|
||||
"last-chapter-added": "Item Added",
|
||||
"time-to-read": "Time to Read",
|
||||
"release-year": "Release Year",
|
||||
"read-progress": "Last Read"
|
||||
"read-progress": "Last Read",
|
||||
"average-rating": "Average Rating"
|
||||
},
|
||||
|
||||
"edit-series-modal": {
|
||||
@ -2029,6 +2042,7 @@
|
||||
"change-email-no-email": "Email has been updated",
|
||||
"device-updated": "Device updated",
|
||||
"device-created": "Device created",
|
||||
"delete-device": "Are you sure you want to delete this device?",
|
||||
"confirm-regen-covers": "Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don't want to perform a Scan instead?",
|
||||
"alert-long-running": "This is a long running process. Please give it the time to complete before invoking again.",
|
||||
"confirm-delete-multiple-series": "Are you sure you want to delete {{count}} series? It will not modify files on disk.",
|
||||
|
61
openapi.json
61
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.7.13.10"
|
||||
"version": "0.7.13.11"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -9458,6 +9458,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Server/check-for-updates": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Checks for updates and pushes an event to the UI",
|
||||
"description": "Some users have websocket issues so this is not always reliable to alert the user",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Server/check-update": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -9664,19 +9678,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Server/check-for-updates": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Checks for updates and pushes an event to the UI",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Settings/base-url": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -12545,7 +12546,7 @@
|
||||
"wantToRead": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Series"
|
||||
"$ref": "#/components/schemas/AppUserWantToRead"
|
||||
},
|
||||
"description": "A list of Series the user want's to read",
|
||||
"nullable": true
|
||||
@ -13264,6 +13265,31 @@
|
||||
"additionalProperties": false,
|
||||
"description": "A personal table of contents for a given user linked with a given book"
|
||||
},
|
||||
"AppUserWantToRead": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"seriesId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"series": {
|
||||
"$ref": "#/components/schemas/Series"
|
||||
},
|
||||
"appUser": {
|
||||
"$ref": "#/components/schemas/AppUser"
|
||||
},
|
||||
"appUserId": {
|
||||
"type": "integer",
|
||||
"description": "User this table of content belongs to",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BookChapterItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -19034,7 +19060,8 @@
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7
|
||||
7,
|
||||
8
|
||||
],
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
@ -20339,7 +20366,7 @@
|
||||
},
|
||||
"number": {
|
||||
"type": "number",
|
||||
"description": "This will map to MinNumber. Number was removed in v0.7.13.8",
|
||||
"description": "This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14",
|
||||
"format": "float",
|
||||
"deprecated": true
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user