Bookmark Refactor (#893)

* Fixed a bug which didn't take sort direction when not changing sort field

* Added foundation for Bookmark refactor

* Code broken, need to take a break. Issue is Getting bookmark image needs authentication but UI doesn't send.

* Implemented the ability to send bookmarked files to the web. Implemented ability to clear bookmarks on disk on a re-occuring basis.

* Updated the bookmark design to have it's own card that is self contained. View bookmarks modal has been updated to better lay out the cards.

* Refactored download bookmark codes to select files from bookmark directory directly rather than open underlying files.

* Wrote the basic logic to kick start the bookmark migration.

Added Installed Version into the DB to allow us to know more accurately when to run migrations

* Implemented the ability to change the bookmarks directory

* Updated all references to BookmarkDirectory to use setting from the DB.

Updated Server Settings page to use 2 col for some rows.

* Refactored some code to DirectoryService (hasWriteAccess) and fixed up some unit tests from a previous PR.

* Treat folders that start with ._ as blacklisted.

* Implemented Reset User preferences. Some extra code to prep for the migration.

* Implemented a migration for existing bookmarks to using new filesystem based bookmarks
This commit is contained in:
Joseph Milazzo 2022-01-05 09:56:49 -08:00 committed by GitHub
parent 04ffd1ef6f
commit a1a6333f09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 2006 additions and 103 deletions

1
.gitignore vendored
View File

@ -508,6 +508,7 @@ UI/Web/dist/
/API/config/cache/
/API/config/temp/
/API/config/stats/
/API/config/bookmarks/
/API/config/kavita.db
/API/config/kavita.db-shm
/API/config/kavita.db-wal

View File

@ -10,7 +10,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="NSubstitute" Version="4.2.2" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="14.0.13" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="16.0.1" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -78,6 +78,7 @@ namespace API.Tests.Parser
[InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "Chevaliers d'Héliopolis")]
[InlineData("Bd Fr-Aldebaran-Antares-t6", "Aldebaran-Antares")]
[InlineData("Tintin - T22 Vol 714 pour Sydney", "Tintin")]
[InlineData("Fables 2010 Vol. 1 Legends in Exile", "Fables 2010")]
public void ParseComicSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename));

View File

@ -379,9 +379,10 @@ namespace API.Tests.Services
{
const string testDirectory = "c:/manga/";
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory(testDirectory);
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
Assert.False(ds.IsDirectoryEmpty("c:/manga/"));
Assert.True(ds.IsDirectoryEmpty("c:/manga/"));
}
[Fact]
@ -733,6 +734,25 @@ namespace API.Tests.Services
Assert.True(fileSystem.Directory.Exists($"{testDirectory}subdir/"));
}
#endregion
#region CheckWriteAccess
[Fact]
public async Task CheckWriteAccess_ShouldHaveAccess()
{
const string testDirectory = "/manga/";
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var hasAccess = await ds.CheckWriteAccess(ds.FileSystem.Path.Join(testDirectory, "bookmarks"));
Assert.True(hasAccess);
Assert.False(ds.FileSystem.Directory.Exists(ds.FileSystem.Path.Join(testDirectory, "bookmarks")));
Assert.False(ds.FileSystem.File.Exists(ds.FileSystem.Path.Join(testDirectory, "bookmarks", "test.txt")));
}
#endregion
}
}

View File

@ -70,7 +70,7 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.15.0" />
<PackageReference Include="System.IO.Abstractions" Version="14.0.13" />
<PackageReference Include="System.IO.Abstractions" Version="16.0.1" />
<PackageReference Include="VersOne.Epub" Version="3.0.3.1" />
</ItemGroup>

View File

@ -136,10 +136,10 @@ namespace API.Controllers
public async Task<ActionResult> DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto)
{
// We know that all bookmarks will be for one single seriesId
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId);
var totalFilePaths = new List<string>();
var tempFolder = $"download_{series.Id}_bookmarks";
var tempFolder = $"download_{user.Id}_{series.Id}_bookmarks";
var fullExtractPath = Path.Join(_directoryService.TempDirectory, tempFolder);
if (_directoryService.FileSystem.DirectoryInfo.FromDirectoryName(fullExtractPath).Exists)
{
@ -148,42 +148,14 @@ namespace API.Controllers
}
_directoryService.ExistOrCreate(fullExtractPath);
var uniqueChapterIds = downloadBookmarkDto.Bookmarks.Select(b => b.ChapterId).Distinct().ToList();
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var files = (await _unitOfWork.UserRepository.GetAllBookmarksByIds(downloadBookmarkDto.Bookmarks
.Select(b => b.Id)
.ToList()))
.Select(b => _directoryService.FileSystem.Path.Join(bookmarkDirectory, b.FileName));
foreach (var chapterId in uniqueChapterIds)
{
var chapterExtractPath = Path.Join(fullExtractPath, $"{series.Id}_bookmark_{chapterId}");
var chapterPages = downloadBookmarkDto.Bookmarks.Where(b => b.ChapterId == chapterId)
.Select(b => b.Page).ToList();
var mangaFiles = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
switch (series.Format)
{
case MangaFormat.Image:
_directoryService.ExistOrCreate(chapterExtractPath);
_directoryService.CopyFilesToDirectory(mangaFiles.Select(f => f.FilePath), chapterExtractPath, $"{chapterId}_");
break;
case MangaFormat.Archive:
case MangaFormat.Pdf:
_cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList());
var originalFiles = _directoryService.GetFilesWithExtension(chapterExtractPath,
Parser.Parser.ImageFileExtensions);
_directoryService.CopyFilesToDirectory(originalFiles, chapterExtractPath, $"{chapterId}_");
_directoryService.DeleteFiles(originalFiles);
break;
case MangaFormat.Epub:
return BadRequest("Series is not in a valid format.");
default:
return BadRequest("Series is not in a valid format. Please rescan series and try again.");
}
var files = _directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
// Filter out images that aren't in bookmarks
Array.Sort(files, _numericComparer);
totalFilePaths.AddRange(files.Where((_, i) => chapterPages.Contains(i)));
}
var (fileBytes, _) = await _archiveService.CreateZipForDownload(totalFilePaths,
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files,
tempFolder);
_directoryService.ClearAndDeleteDirectory(fullExtractPath);
return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip");

View File

@ -1,6 +1,7 @@
using System.IO;
using System.Threading.Tasks;
using API.Data;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using Microsoft.AspNetCore.Mvc;
@ -85,5 +86,27 @@ namespace API.Controllers
Response.AddCacheHeader(path);
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
}
/// <summary>
/// Returns image for a given bookmark page
/// </summary>
/// <remarks>This request is served unauthenticated, but user must be passed via api key to validate</remarks>
/// <param name="chapterId"></param>
/// <param name="pageNum">Starts at 0</param>
/// <param name="apiKey">API Key for user. Needed to authenticate request</param>
/// <returns></returns>
[HttpGet("bookmark")]
public async Task<ActionResult> GetBookmarkImage(int chapterId, int pageNum, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, userId);
if (bookmark == null) return BadRequest("Bookmark does not exist");
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var file = new FileInfo(Path.Join(bookmarkDirectory, bookmark.FileName));
var format = Path.GetExtension(file.FullName).Replace(".", "");
return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName));
}
}
}

View File

@ -8,8 +8,10 @@ using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@ -24,15 +26,21 @@ namespace API.Controllers
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderController> _logger;
private readonly IReaderService _readerService;
private readonly IDirectoryService _directoryService;
private readonly ICleanupService _cleanupService;
/// <inheritdoc />
public ReaderController(ICacheService cacheService,
IUnitOfWork unitOfWork, ILogger<ReaderController> logger, IReaderService readerService)
IUnitOfWork unitOfWork, ILogger<ReaderController> logger,
IReaderService readerService, IDirectoryService directoryService,
ICleanupService cleanupService)
{
_cacheService = cacheService;
_unitOfWork = unitOfWork;
_logger = logger;
_readerService = readerService;
_directoryService = directoryService;
_cleanupService = cleanupService;
}
/// <summary>
@ -398,6 +406,14 @@ namespace API.Controllers
if (await _unitOfWork.CommitAsync())
{
try
{
await _cleanupService.CleanupBookmarks();
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue cleaning up old bookmarks");
}
return Ok();
}
}
@ -455,6 +471,18 @@ namespace API.Controllers
var userBookmark =
await _unitOfWork.UserRepository.GetBookmarkForPage(bookmarkDto.Page, bookmarkDto.ChapterId, user.Id);
// We need to get the image
var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
if (chapter == null) return BadRequest("There was an issue finding image file for reading");
var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page);
var fileInfo = new FileInfo(path);
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
_directoryService.CopyFileToDirectory(path, Path.Join(bookmarkDirectory,
$"{user.Id}", $"{bookmarkDto.SeriesId}", $"{bookmarkDto.ChapterId}"));
if (userBookmark == null)
{
user.Bookmarks ??= new List<AppUserBookmark>();
@ -464,6 +492,8 @@ namespace API.Controllers
VolumeId = bookmarkDto.VolumeId,
SeriesId = bookmarkDto.SeriesId,
ChapterId = bookmarkDto.ChapterId,
FileName = Path.Join($"{user.Id}", $"{bookmarkDto.SeriesId}", $"{bookmarkDto.ChapterId}", fileInfo.Name)
});
_unitOfWork.UserRepository.Update(user);
}

View File

@ -9,6 +9,7 @@ using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Converters;
using API.Services;
using AutoMapper;
using Kavita.Common;
using Kavita.Common.Extensions;
using Microsoft.AspNetCore.Authorization;
@ -23,13 +24,18 @@ namespace API.Controllers
private readonly IUnitOfWork _unitOfWork;
private readonly ITaskScheduler _taskScheduler;
private readonly IAccountService _accountService;
private readonly IDirectoryService _directoryService;
private readonly IMapper _mapper;
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, IAccountService accountService)
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler,
IAccountService accountService, IDirectoryService directoryService, IMapper mapper)
{
_logger = logger;
_unitOfWork = unitOfWork;
_taskScheduler = taskScheduler;
_accountService = accountService;
_directoryService = directoryService;
_mapper = mapper;
}
[AllowAnonymous]
@ -45,11 +51,26 @@ namespace API.Controllers
public async Task<ActionResult<ServerSettingDto>> GetSettings()
{
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
// TODO: Is this needed as it gets updated in the DB on startup
settingsDto.Port = Configuration.Port;
settingsDto.LoggingLevel = Configuration.LogLevel;
return Ok(settingsDto);
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("reset")]
public async Task<ActionResult<ServerSettingDto>> ResetSettings()
{
_logger.LogInformation("{UserName} is resetting Server Settings", User.GetUsername());
// We do not allow CacheDirectory changes, so we will ignore.
// var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync();
// currentSettings = Seed.DefaultSettings;
return await UpdateSettings(_mapper.Map<ServerSettingDto>(Seed.DefaultSettings));
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost]
public async Task<ActionResult<ServerSettingDto>> UpdateSettings(ServerSettingDto updateSettingsDto)
@ -69,6 +90,20 @@ namespace API.Controllers
// We do not allow CacheDirectory changes, so we will ignore.
var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync();
var updateAuthentication = false;
var updateBookmarks = false;
var originalBookmarkDirectory = _directoryService.BookmarkDirectory;
var bookmarkDirectory = updateSettingsDto.BookmarksDirectory;
if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") &&
!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/"))
{
bookmarkDirectory = _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks");
}
if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory))
{
bookmarkDirectory = _directoryService.BookmarkDirectory;
}
foreach (var setting in currentSettings)
{
@ -117,6 +152,22 @@ namespace API.Controllers
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value)
{
// Validate new directory can be used
if (!await _directoryService.CheckWriteAccess(bookmarkDirectory))
{
return BadRequest("Bookmark Directory does not have correct permissions for Kavita to use");
}
originalBookmarkDirectory = setting.Value;
// Normalize the path deliminators. Just to look nice in DB, no functionality
setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory);
_unitOfWork.SettingsRepository.Update(setting);
updateBookmarks = true;
}
if (setting.Key == ServerSettingKey.EnableAuthentication && updateSettingsDto.EnableAuthentication + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableAuthentication + string.Empty;
@ -159,6 +210,13 @@ namespace API.Controllers
_logger.LogInformation("Server authentication changed. Updated all non-admins to default password");
}
if (updateBookmarks)
{
_directoryService.ExistOrCreate(bookmarkDirectory);
_directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory);
_directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory);
}
}
catch (Exception ex)
{

View File

@ -1,4 +1,6 @@
namespace API.DTOs.Settings
using API.Services;
namespace API.DTOs.Settings
{
public class ServerSettingDto
{
@ -30,5 +32,10 @@
/// Base Url for the kavita. Requires restart to take effect.
/// </summary>
public string BaseUrl { get; set; }
/// <summary>
/// Where Bookmarks are stored.
/// </summary>
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="DirectoryService.BookmarkDirectory"/></remarks>
public string BookmarksDirectory { get; set; }
}
}

View File

@ -0,0 +1,102 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Entities.Enums;
using API.Services;
using Microsoft.Extensions.Logging;
namespace API.Data;
/// <summary>
/// Responsible to migrate existing bookmarks to files
/// </summary>
public static class MigrateBookmarks
{
private static readonly Version VersionBookmarksChanged = new Version(0, 4, 9, 27);
/// <summary>
/// This will migrate existing bookmarks to bookmark folder based
/// </summary>
/// <remarks>Bookmark directory is configurable. This will always use the default bookmark directory.</remarks>
/// <param name="directoryService"></param>
/// <returns></returns>
public static async Task Migrate(IDirectoryService directoryService, IUnitOfWork unitOfWork,
ILogger<Program> logger, ICacheService cacheService)
{
var bookmarkDirectory = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory))
.Value;
if (string.IsNullOrEmpty(bookmarkDirectory))
{
bookmarkDirectory = directoryService.BookmarkDirectory;
}
if (directoryService.Exists(bookmarkDirectory)) return;
logger.LogInformation("Bookmark migration is needed....This may take some time");
var allBookmarks = (await unitOfWork.UserRepository.GetAllBookmarksAsync()).ToList();
var uniqueChapterIds = allBookmarks.Select(b => b.ChapterId).Distinct().ToList();
var uniqueUserIds = allBookmarks.Select(b => b.AppUserId).Distinct().ToList();
foreach (var userId in uniqueUserIds)
{
foreach (var chapterId in uniqueChapterIds)
{
var chapterBookmarks = allBookmarks.Where(b => b.ChapterId == chapterId).ToList();
var chapterPages = chapterBookmarks
.Select(b => b.Page).ToList();
var seriesId = chapterBookmarks
.Select(b => b.SeriesId).First();
var mangaFiles = await unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
var chapterExtractPath = directoryService.FileSystem.Path.Join(directoryService.TempDirectory, $"bookmark_c{chapterId}_u{userId}_s{seriesId}");
var numericComparer = new NumericComparer();
if (!mangaFiles.Any()) continue;
switch (mangaFiles.First().Format)
{
case MangaFormat.Image:
directoryService.ExistOrCreate(chapterExtractPath);
directoryService.CopyFilesToDirectory(mangaFiles.Select(f => f.FilePath), chapterExtractPath);
break;
case MangaFormat.Archive:
case MangaFormat.Pdf:
cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList());
break;
case MangaFormat.Epub:
continue;
default:
continue;
}
var files = directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
// Filter out images that aren't in bookmarks
Array.Sort(files, numericComparer);
foreach (var chapterPage in chapterPages)
{
var file = files.ElementAt(chapterPage);
var bookmark = allBookmarks.FirstOrDefault(b =>
b.ChapterId == chapterId && b.SeriesId == seriesId && b.AppUserId == userId &&
b.Page == chapterPage);
if (bookmark == null) continue;
var filename = directoryService.FileSystem.Path.GetFileName(file);
var newLocation = directoryService.FileSystem.Path.Join(
ReaderService.FormatBookmarkFolderPath(String.Empty, userId, seriesId, chapterId),
filename);
bookmark.FileName = newLocation;
directoryService.CopyFileToDirectory(file,
ReaderService.FormatBookmarkFolderPath(bookmarkDirectory, userId, seriesId, chapterId));
unitOfWork.UserRepository.Update(bookmark);
}
}
// Clear temp after each user to avoid too much space being eaten
directoryService.ClearDirectory(directoryService.TempDirectory);
}
await unitOfWork.CommitAsync();
// Run CleanupService as we cache a ton of files
directoryService.ClearDirectory(directoryService.TempDirectory);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class BookmarkRefactor : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FileName",
table: "AppUserBookmark",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FileName",
table: "AppUserBookmark");
}
}
}

View File

@ -134,6 +134,9 @@ namespace API.Data.Migrations
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<int>("Page")
.HasColumnType("INTEGER");

View File

@ -39,12 +39,15 @@ public interface IUserRepository
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId);
Task<IEnumerable<AppUserBookmark>> GetAllBookmarksAsync();
Task<AppUserBookmark> GetBookmarkForPage(int page, int chapterId, int userId);
Task<AppUserBookmark> GetBookmarkAsync(int bookmarkId);
Task<int> GetUserIdByApiKeyAsync(string apiKey);
Task<AppUser> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None);
Task<AppUser> GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None);
Task<int> GetUserIdByUsernameAsync(string username);
Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username);
Task<IList<AppUserBookmark>> GetAllBookmarksByIds(IList<int> bookmarkIds);
}
public class UserRepository : IUserRepository
@ -112,6 +115,11 @@ public class UserRepository : IUserRepository
return await query.SingleOrDefaultAsync();
}
public async Task<IEnumerable<AppUserBookmark>> GetAllBookmarksAsync()
{
return await _context.AppUserBookmark.ToListAsync();
}
public async Task<AppUserBookmark> GetBookmarkForPage(int page, int chapterId, int userId)
{
return await _context.AppUserBookmark
@ -119,6 +127,13 @@ public class UserRepository : IUserRepository
.SingleOrDefaultAsync();
}
public async Task<AppUserBookmark> GetBookmarkAsync(int bookmarkId)
{
return await _context.AppUserBookmark
.Where(b => b.Id == bookmarkId)
.SingleOrDefaultAsync();
}
private static IQueryable<AppUser> AddIncludesToQuery(IQueryable<AppUser> query, AppUserIncludes includeFlags)
{
if (includeFlags.HasFlag(AppUserIncludes.Bookmarks))
@ -171,6 +186,18 @@ public class UserRepository : IUserRepository
.SingleOrDefaultAsync(x => x.UserName == username);
}
/// <summary>
/// Returns all Bookmarks for a given set of Ids
/// </summary>
/// <param name="bookmarkIds"></param>
/// <returns></returns>
public async Task<IList<AppUserBookmark>> GetAllBookmarksByIds(IList<int> bookmarkIds)
{
return await _context.AppUserBookmark
.Where(b => bookmarkIds.Contains(b.Id))
.ToListAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);

View File

@ -8,6 +8,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Services;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@ -15,6 +16,11 @@ namespace API.Data
{
public static class Seed
{
/// <summary>
/// Generated on Startup. Seed.SeedSettings must run before
/// </summary>
public static IList<ServerSetting> DefaultSettings;
public static async Task SeedRoles(RoleManager<AppRole> roleManager)
{
var roles = typeof(PolicyConstants)
@ -39,7 +45,7 @@ namespace API.Data
{
await context.Database.EnsureCreatedAsync();
IList<ServerSetting> defaultSettings = new List<ServerSetting>()
DefaultSettings = new List<ServerSetting>()
{
new () {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory},
new () {Key = ServerSettingKey.TaskScan, Value = "daily"},
@ -52,9 +58,11 @@ namespace API.Data
new () {Key = ServerSettingKey.EnableAuthentication, Value = "true"},
new () {Key = ServerSettingKey.BaseUrl, Value = "/"},
new () {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()},
new () {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()},
new () {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
};
foreach (var defaultSetting in defaultSettings)
foreach (var defaultSetting in DefaultSettings)
{
var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key);
if (existing == null)

View File

@ -12,6 +12,10 @@ namespace API.Entities
public int VolumeId { get; set; }
public int SeriesId { get; set; }
public int ChapterId { get; set; }
/// <summary>
/// Filename in the Bookmark Directory
/// </summary>
public string FileName { get; set; }
// Relationships

View File

@ -58,7 +58,18 @@ namespace API.Entities.Enums
/// Represents this installation of Kavita. Is tied to Stat reporting but has no information about user or files.
/// </summary>
[Description("InstallId")]
InstallId = 10
InstallId = 10,
/// <summary>
/// Represents the version the software is running.
/// </summary>
/// <remarks>This will be updated on Startup to the latest release. Provides ability to detect if certain migrations need to be run.</remarks>
[Description("InstallVersion")]
InstallVersion = 11,
/// <summary>
/// Location of where bookmarks are stored
/// </summary>
[Description("BookmarkDirectory")]
BookmarkDirectory = 12,
}
}

View File

@ -42,6 +42,9 @@ namespace API.Helpers.Converters
case ServerSettingKey.BaseUrl:
destination.BaseUrl = row.Value;
break;
case ServerSettingKey.BookmarkDirectory:
destination.BookmarksDirectory = row.Value;
break;
}
}

View File

@ -1097,7 +1097,7 @@ namespace API.Parser
public static bool HasBlacklistedFolderInPath(string path)
{
return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot");
return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("._");
}

View File

@ -6,6 +6,7 @@ using System.Security.Cryptography;
using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks;
using Kavita.Common;
@ -80,6 +81,7 @@ namespace API
requiresCoverImageMigration = false;
}
// Apply all migrations on startup
// If we have pending migrations, make a backup first
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();

View File

@ -21,6 +21,10 @@ namespace API.Services
string TempDirectory { get; }
string ConfigDirectory { get; }
/// <summary>
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettings.BackupDirectory"/> for actual path.
/// </summary>
string BookmarkDirectory { get; }
/// <summary>
/// Lists out top-level folders for a given directory. Filters out System and Hidden folders.
/// </summary>
/// <param name="rootPath">Absolute path of directory to scan.</param>
@ -50,7 +54,7 @@ namespace API.Services
void DeleteFiles(IEnumerable<string> files);
void RemoveNonImages(string directoryName);
void Flatten(string directoryName);
Task<bool> CheckWriteAccess(string directoryName);
}
public class DirectoryService : IDirectoryService
{
@ -60,6 +64,7 @@ namespace API.Services
public string LogDirectory { get; }
public string TempDirectory { get; }
public string ConfigDirectory { get; }
public string BookmarkDirectory { get; }
private readonly ILogger<DirectoryService> _logger;
private static readonly Regex ExcludeDirectories = new Regex(
@ -76,6 +81,7 @@ namespace API.Services
LogDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "logs");
TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp");
ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config");
BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks");
}
/// <summary>
@ -268,7 +274,7 @@ namespace API.Services
/// <returns></returns>
public bool IsDirectoryEmpty(string path)
{
return Directory.EnumerateFileSystemEntries(path).Any();
return FileSystem.Directory.Exists(path) && !FileSystem.Directory.EnumerateFileSystemEntries(path).Any();
}
public string[] GetFilesWithExtension(string path, string searchPatternExpression = "")
@ -682,6 +688,30 @@ namespace API.Services
FlattenDirectory(directory, directory, ref index);
}
/// <summary>
/// Checks whether a directory has write permissions
/// </summary>
/// <param name="directoryName">Fully qualified path</param>
/// <returns></returns>
public async Task<bool> CheckWriteAccess(string directoryName)
{
try
{
ExistOrCreate(directoryName);
await FileSystem.File.WriteAllTextAsync(
FileSystem.Path.Join(directoryName, "test.txt"),
string.Empty);
}
catch (Exception ex)
{
ClearAndDeleteDirectory(directoryName);
return false;
}
ClearAndDeleteDirectory(directoryName);
return true;
}
private void FlattenDirectory(IDirectoryInfo root, IDirectoryInfo directory, ref int directoryIndex)
{

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
@ -19,19 +20,29 @@ public interface IReaderService
Task<int> CapPageToChapter(int chapterId, int page);
Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
//Task<string> BookmarkFile();
}
public class ReaderService : IReaderService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderService> _logger;
private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger)
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger, IDirectoryService directoryService, ICacheService cacheService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_directoryService = directoryService;
_cacheService = cacheService;
}
public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId)
{
return Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}");
}
/// <summary>
@ -299,6 +310,17 @@ public class ReaderService : IReaderService
return -1;
}
// public async Task<string> BookmarkFile()
// {
// var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
// if (chapter == null) return string.Empty;
// var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page);
// var fileInfo = new FileInfo(path);
//
// return _directoryService.CopyFileToDirectory(path, Path.Join(_directoryService.BookmarkDirectory,
// $"{user.Id}", $"{bookmarkDto.SeriesId}"));
// }
private static int GetNextChapterId(IEnumerable<ChapterDto> chapters, string currentChapterNumber)
{
var next = false;

View File

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
@ -19,6 +20,7 @@ namespace API.Services.Tasks
Task DeleteChapterCoverImages();
Task DeleteTagCoverImages();
Task CleanupBackups();
Task CleanupBookmarks();
}
/// <summary>
/// Cleans up after operations on reoccurring basis
@ -63,6 +65,9 @@ namespace API.Services.Tasks
await DeleteChapterCoverImages();
await SendProgress(0.7F);
await DeleteTagCoverImages();
await SendProgress(0.8F);
_logger.LogInformation("Cleaning old bookmarks");
await CleanupBookmarks();
await SendProgress(1F);
_logger.LogInformation("Cleanup finished");
}
@ -163,5 +168,34 @@ namespace API.Services.Tasks
}
_logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now);
}
/// <summary>
/// Removes all files in the BookmarkDirectory that don't currently have bookmarks in the Database
/// </summary>
public async Task CleanupBookmarks()
{
// Search all files in bookmarks/
// except bookmark files and delete those
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var allBookmarkFiles = _directoryService.GetFiles(bookmarkDirectory, searchOption: SearchOption.AllDirectories);
var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync())
.Select(b => _directoryService.FileSystem.Path.Join(bookmarkDirectory,
b.FileName));
var filesToDelete = allBookmarkFiles.Except(bookmarks);
_directoryService.DeleteFiles(filesToDelete);
// Clear all empty directories
foreach (var directory in _directoryService.FileSystem.Directory.GetDirectories(bookmarkDirectory))
{
if (_directoryService.FileSystem.Directory.GetFiles(directory).Length == 0 &&
_directoryService.FileSystem.Directory.GetDirectories(directory).Length == 0)
{
_directoryService.FileSystem.Directory.Delete(directory, false);
}
}
}
}
}

View File

@ -84,6 +84,8 @@ namespace API.Services.Tasks.Scanner
{
ParserInfo info = null;
// TODO: Emit event with what is being processed. It can look like Kavita isn't doing anything during file scan
if (Parser.Parser.IsEpub(path))
{
info = _readingItemService.Parse(path, rootPath, type);
@ -114,8 +116,6 @@ namespace API.Services.Tasks.Scanner
info.ComicInfo = GetComicInfo(path);
if (info.ComicInfo != null)
{
var sw = Stopwatch.StartNew();
if (!string.IsNullOrEmpty(info.ComicInfo.Volume))
{
info.Volumes = info.ComicInfo.Volume;

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@ -222,9 +222,12 @@ public class ScannerService : IScannerService
}
// For Docker instances check if any of the folder roots are not available (ie disconnected volumes, etc) and fail if any of them are
if (library.Folders.Any(f => !_directoryService.IsDirectoryEmpty(f.Path)))
if (library.Folders.Any(f => _directoryService.IsDirectoryEmpty(f.Path)))
{
_logger.LogError("Some of the root folders for the library are empty. Either your mount has been disconnected or you are trying to delete all series in the library. Scan will be aborted. Check that your mount is connected or change the library's root folder and rescan.");
_logger.LogError("Some of the root folders for the library are empty. " +
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
"Scan will be aborted. " +
"Check that your mount is connected or change the library's root folder and rescan");
return;
}

View File

@ -4,6 +4,8 @@ using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using API.Data;
using API.Extensions;
using API.Middleware;
using API.Services;
@ -24,6 +26,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models;
using TaskScheduler = API.Services.TaskScheduler;
namespace API
{
@ -126,8 +129,15 @@ namespace API
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env,
IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider)
IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService,
IDirectoryService directoryService, IUnitOfWork unitOfWork)
{
// Apply Migrations
Task.Run(async () => await MigrateBookmarks.Migrate(directoryService, unitOfWork,
serviceProvider.GetRequiredService<ILogger<Program>>(), cacheService)).GetAwaiter().GetResult();
app.UseMiddleware<ExceptionMiddleware>();
if (env.IsDevelopment())

View File

@ -5,7 +5,7 @@
"TokenKey": "super secret unguessable key",
"Logging": {
"LogLevel": {
"Default": "Debug",
"Default": "Information",
"Microsoft": "Information",
"Microsoft.Hosting.Lifetime": "Error",
"Hangfire": "Information",

View File

@ -4,4 +4,5 @@ export interface PageBookmark {
seriesId: number;
volumeId: number;
chapterId: number;
fileName: string;
}

View File

@ -1,18 +1,24 @@
import { Injectable } from '@angular/core';
import { Injectable, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { AccountService } from './account.service';
import { NavService } from './nav.service';
@Injectable({
providedIn: 'root'
})
export class ImageService {
export class ImageService implements OnDestroy {
baseUrl = environment.apiUrl;
apiKey: string = '';
public placeholderImage = 'assets/images/image-placeholder-min.png';
public errorImage = 'assets/images/error-placeholder2-min.png';
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
constructor(private navSerivce: NavService) {
private onDestroy: Subject<void> = new Subject();
constructor(private navSerivce: NavService, private accountService: AccountService) {
this.navSerivce.darkMode$.subscribe(res => {
if (res) {
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
@ -22,6 +28,17 @@ export class ImageService {
this.errorImage = 'assets/images/error-placeholder2-min.png';
}
});
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
if (user) {
this.apiKey = user.apiKey;
}
});
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
getVolumeCoverImage(volumeId: number) {
@ -41,7 +58,7 @@ export class ImageService {
}
getBookmarkedImage(chapterId: number, pageNum: number) {
return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId + '&pageNum=' + pageNum;
return this.baseUrl + 'image/bookmark?chapterId=' + chapterId + '&pageNum=' + pageNum + '&apiKey=' + encodeURIComponent(this.apiKey);
}
updateErroredImage(event: any) {

View File

@ -36,6 +36,7 @@
<i class="fa fa-arrow-left mr-2" aria-hidden="true"></i>
Back
</button>
<button type="button" class="btn btn-primary float-right" [disabled]="routeStack.peek() === undefined" (click)="shareFolder('', $event)">Share</button>
</div>
</ul>
@ -50,6 +51,6 @@
</ul>
</div>
<div class="modal-footer">
<a class="btn btn-info" href="https://wiki.kavitareader.com/en/guides/adding-a-library" target="_blank">Help</a>
<a class="btn btn-info" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank">Help</a>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
</div>

View File

@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core';
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Stack } from 'src/app/shared/data-structures/stack';
import { LibraryService } from '../../../_services/library.service';
@ -17,6 +17,12 @@ export interface DirectoryPickerResult {
})
export class DirectoryPickerComponent implements OnInit {
@Input() startingFolder: string = '';
/**
* Url to give more information about selecting directories. Passing nothing will suppress.
*/
@Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/adding-a-library';
currentRoot = '';
folders: string[] = [];
routeStack: Stack<string> = new Stack<string>();
@ -27,7 +33,22 @@ export class DirectoryPickerComponent implements OnInit {
}
ngOnInit(): void {
this.loadChildren(this.currentRoot);
if (this.startingFolder && this.startingFolder.length > 0) {
let folders = this.startingFolder.split('/');
let folders2 = this.startingFolder.split('\\');
if (folders.length === 1 && folders2.length > 1) {
folders = folders2;
}
if (!folders[0].endsWith('/')) {
folders[0] = folders[0] + '/';
}
folders.forEach(folder => this.routeStack.push(folder));
const fullPath = this.routeStack.items.join('/');
this.loadChildren(fullPath);
} else {
this.loadChildren(this.currentRoot);
}
}
filterFolder = (folder: string) => {
@ -38,7 +59,7 @@ export class DirectoryPickerComponent implements OnInit {
this.currentRoot = folderName;
this.routeStack.push(folderName);
const fullPath = this.routeStack.items.join('/');
this.loadChildren(fullPath);
this.loadChildren(fullPath);
}
goBack() {
@ -86,7 +107,7 @@ export class DirectoryPickerComponent implements OnInit {
if (lastPath && lastPath != path) {
let replaced = path.replace(lastPath, '');
if (replaced.startsWith('/') || replaced.startsWith('\\')) {
replaced = replaced.substr(1, replaced.length);
replaced = replaced.substring(1, replaced.length);
}
return replaced;
}
@ -95,14 +116,11 @@ export class DirectoryPickerComponent implements OnInit {
}
navigateTo(index: number) {
const numberOfPops = this.routeStack.items.length - index;
if (this.routeStack.items.length - numberOfPops > this.routeStack.items.length) {
this.routeStack.items = [];
}
for (let i = 0; i < numberOfPops; i++) {
while(this.routeStack.items.length - 1 > index) {
this.routeStack.pop();
}
this.loadChildren(this.routeStack.peek() || '');
const fullPath = this.routeStack.items.join('/');
this.loadChildren(fullPath);
}
}

View File

@ -8,4 +8,5 @@ export interface ServerSettings {
enableOpds: boolean;
enableAuthentication: boolean;
baseUrl: string;
bookmarksDirectory: string;
}

View File

@ -8,6 +8,20 @@
<input readonly id="settings-cachedir" aria-describedby="settings-cachedir-help" class="form-control" formControlName="cacheDirectory" type="text">
</div>
<div class="form-group">
<label for="settings-bookmarksdir">Bookmarks Directory</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="bookmarksDirectoryTooltip" role="button" tabindex="0"></i>
<ng-template #bookmarksDirectoryTooltip>Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed, other files within directory will be deleted.</ng-template>
<span class="sr-only" id="settings-bookmarksdir-help"><ng-container [ngTemplateOutlet]="bookmarksDirectoryTooltip"></ng-container></span>
<div class="input-group">
<input readonly id="settings-bookmarksdir" aria-describedby="settings-bookmarksdir-help" class="form-control" formControlName="bookmarksDirectory" type="text" aria-describedby="change-bookmarks-dir">
<div class="input-group-append">
<button id="change-bookmarks-dir" class="btn btn-primary" (click)="openDirectoryChooser(settingsForm.get('bookmarksDirectory')?.value, 'bookmarksDirectory')">
Change
</button>
</div>
</div>
</div>
<!-- <div class="form-group">
<label for="settings-baseurl">Base Url</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
<ng-template #baseUrlTooltip>Use this if you want to host Kavita on a base url ie) yourdomain.com/kavita</ng-template>
@ -15,20 +29,22 @@
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text">
</div> -->
<div class="form-group">
<label for="settings-port">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
<span class="sr-only" id="settings-port-help">Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</span>
<input id="settings-port" aria-describedby="settings-port-help" class="form-control" formControlName="port" type="number" step="1" min="1" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
</div>
<div class="form-group">
<label for="logging-level-port">Logging Level</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect.</ng-template>
<span class="sr-only" id="logging-level-port-help">Port the server listens on. Requires restart to take effect.</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-control" aria-describedby="settings-tasks-scan-help" formControlName="loggingLevel">
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
</select>
<div class="row no-gutters">
<div class="form-group col-md-6 col-sm-12 pr-2">
<label for="settings-port">Port</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="portTooltip" role="button" tabindex="0"></i>
<ng-template #portTooltip>Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</ng-template>
<span class="sr-only" id="settings-port-help">Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect.</span>
<input id="settings-port" aria-describedby="settings-port-help" class="form-control" formControlName="port" type="number" step="1" min="1" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
</div>
<div class="form-group col-md-6 col-sm-12">
<label for="logging-level-port">Logging Level</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect.</ng-template>
<span class="sr-only" id="logging-level-port-help">Port the server listens on. Requires restart to take effect.</span>
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-control" aria-describedby="settings-tasks-scan-help" formControlName="loggingLevel">
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
</select>
</div>
</div>
<div class="form-group">
@ -78,6 +94,7 @@
</div>
<div class="float-right">
<button type="button" class="btn btn-secondary mr-2" (click)="resetToDefaults()">Reset to Default</button>
<button type="button" class="btn btn-secondary mr-2" (click)="resetForm()">Reset</button>
<button type="submit" class="btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>

View File

@ -1,9 +1,11 @@
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { SettingsService } from '../settings.service';
import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component';
import { ServerSettings } from '../_models/server-settings';
@Component({
@ -18,7 +20,8 @@ export class ManageSettingsComponent implements OnInit {
taskFrequencies: Array<string> = [];
logLevels: Array<string> = [];
constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService) { }
constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService,
private modalService: NgbModal) { }
ngOnInit(): void {
this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => {
@ -30,6 +33,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.settingsForm.addControl('cacheDirectory', new FormControl(this.serverSettings.cacheDirectory, [Validators.required]));
this.settingsForm.addControl('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [Validators.required]));
this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required]));
this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required]));
this.settingsForm.addControl('port', new FormControl(this.serverSettings.port, [Validators.required]));
@ -43,6 +47,7 @@ export class ManageSettingsComponent implements OnInit {
resetForm() {
this.settingsForm.get('cacheDirectory')?.setValue(this.serverSettings.cacheDirectory);
this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory);
this.settingsForm.get('scanTask')?.setValue(this.serverSettings.taskScan);
this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup);
this.settingsForm.get('port')?.setValue(this.serverSettings.port);
@ -77,4 +82,26 @@ export class ManageSettingsComponent implements OnInit {
});
}
resetToDefaults() {
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
this.toastr.success('Server settings updated');
}, (err: any) => {
console.error('error: ', err);
});
}
openDirectoryChooser(existingDirectory: string, formControl: string) {
const modalRef = this.modalService.open(DirectoryPickerComponent, { scrollable: true, size: 'lg' });
modalRef.componentInstance.startingFolder = existingDirectory || '';
modalRef.componentInstance.helpUrl = '';
modalRef.closed.subscribe((closeResult: DirectoryPickerResult) => {
if (closeResult.success) {
this.settingsForm.get(formControl)?.setValue(closeResult.folderPath);
this.settingsForm.markAsTouched();
}
});
}
}

View File

@ -21,6 +21,10 @@ export class SettingsService {
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
}
resetServerSettings() {
return this.http.post<ServerSettings>(this.baseUrl + 'settings/reset', {});
}
getTaskFrequencies() {
return this.http.get<string[]>(this.baseUrl + 'settings/task-frequencies');
}

View File

@ -5,15 +5,16 @@
</button>
</div>
<div class="modal-body">
<ul class="list-unstyled">
<li class="list-group-item" *ngIf="bookmarks.length > 0">
There are {{bookmarks.length}} pages bookmarked over {{uniqueChapters}} files.
</li>
<li class="list-group-item" *ngIf="bookmarks.length === 0">
No bookmarks yet
</li>
</ul>
<p *ngIf="bookmarks.length > 0; else noBookmarks">
There are {{bookmarks.length}} pages bookmarked over {{uniqueChapters}} files.
</p>
<ng-template #noBookmarks>No bookmarks yet</ng-template>
<div class="row no-gutters">
<div *ngFor="let bookmark of bookmarks; let idx = index">
<app-bookmark [bookmark]="bookmark" (bookmarkRemoved)="removeBookmark(bookmark, idx)" class="col-auto"></app-bookmark>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="clearBookmarks()" [disabled]="(isDownloading || isClearing) || bookmarks.length === 0">

View File

@ -51,6 +51,10 @@ export class BookmarksModalComponent implements OnInit {
this.modal.close();
}
removeBookmark(bookmark: PageBookmark, index: number) {
this.bookmarks.splice(index, 1);
}
downloadBookmarks() {
this.isDownloading = true;
this.downloadService.downloadBookmarks(this.bookmarks).pipe(

View File

@ -0,0 +1,28 @@
<div class="card" *ngIf="bookmark != undefined">
<img class="img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getBookmarkedImage(bookmark.chapterId, bookmark.page)"
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="170px">
<div class="card-body" *ngIf="bookmark.page >= 0">
<div class="header-row">
<span class="card-title" tabindex="0">
Page {{bookmark.page + 1}}
</span>
<span class="card-actions float-right" *ngIf="series != undefined">
<button attr.aria-labelledby="series--{{series.name}}" class="btn btn-danger btn-sm" (click)="removeBookmark()"
[disabled]="isClearing" placement="top" ngbTooltip="Remove Bookmark" attr.aria-label="Remove Bookmark">
<ng-container *ngIf="isClearing; else notClearing">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
</ng-container>
<ng-template #notClearing>
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</ng-template>
</button>
</span>
</div>
<div>
<a *ngIf="series != undefined" class="title-overflow library" href="/library/{{series.libraryId}}/series/{{series.id}}"
placement="top" id="bookmark_card_{{series.name}}_{{bookmark.id}}" [ngbTooltip]="series.name | titlecase">{{series.name | titlecase}}</a>
</div>
</div>
</div>

View File

@ -0,0 +1,25 @@
.card-body {
padding: 5px;
}
.card {
margin-left: 5px;
margin-right: 5px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.title-overflow {
font-size: 13px;
width: 130px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
margin-top: 2px;
margin-bottom: 0px;
}

View File

@ -0,0 +1,43 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Series } from 'src/app/_models/series';
import { ImageService } from 'src/app/_services/image.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { SeriesService } from 'src/app/_services/series.service';
import { PageBookmark } from '../../_models/page-bookmark';
@Component({
selector: 'app-bookmark',
templateUrl: './bookmark.component.html',
styleUrls: ['./bookmark.component.scss']
})
export class BookmarkComponent implements OnInit {
@Input() bookmark: PageBookmark | undefined;
@Output() bookmarkRemoved: EventEmitter<PageBookmark> = new EventEmitter<PageBookmark>();
series: Series | undefined;
isClearing: boolean = false;
isDownloading: boolean = false;
constructor(public imageService: ImageService, private seriesService: SeriesService, private readerService: ReaderService) { }
ngOnInit(): void {
if (this.bookmark) {
this.seriesService.getSeries(this.bookmark.seriesId).subscribe(series => {
this.series = series;
});
}
}
handleClick(event: any) {
}
removeBookmark() {
if (this.bookmark === undefined) return;
this.readerService.unbookmark(this.bookmark.seriesId, this.bookmark.volumeId, this.bookmark.chapterId, this.bookmark.page).subscribe(res => {
this.bookmarkRemoved.emit(this.bookmark);
this.bookmark = undefined;
});
}
}

View File

@ -20,13 +20,13 @@
<div class="not-read-badge" *ngIf="read === 0 && total > 0"></div>
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)" *ngIf="allowSelection">
<input type="checkbox" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
<input type="checkbox" attr.aria-labelledby="{{title}}_{{entity?.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
</div>
</div>
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
<div>
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick()" tabindex="0">
<span class="card-title" placement="top" id="{{title}}_{{entity?.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick()" tabindex="0">
<span *ngIf="isPromoted()">
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
<span class="sr-only">(promoted)</span>

View File

@ -8,6 +8,7 @@ import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { MangaFormat } from 'src/app/_models/manga-format';
import { PageBookmark } from 'src/app/_models/page-bookmark';
import { Series } from 'src/app/_models/series';
import { Volume } from 'src/app/_models/volume';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
@ -49,7 +50,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
/**
* This is the entity we are representing. It will be returned if an action is executed.
*/
@Input() entity!: Series | Volume | Chapter | CollectionTag;
@Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark;
/**
* If the entity is selected or not.
*/

View File

@ -8,7 +8,7 @@ import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit
import { ChangeCoverImageModalComponent } from './_modals/change-cover-image/change-cover-image-modal.component';
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
import { LazyLoadImageModule } from 'ng-lazyload-image';
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbAccordionModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NgxFileDropModule } from 'ngx-file-drop';
@ -23,6 +23,7 @@ import { BulkAddToCollectionComponent } from './_modals/bulk-add-to-collection/b
import { PipeModule } from '../pipe/pipe.module';
import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component';
import { FileInfoComponent } from './file-info/file-info.component';
import { BookmarkComponent } from './bookmark/bookmark.component';
@ -42,7 +43,8 @@ import { FileInfoComponent } from './file-info/file-info.component';
BulkOperationsComponent,
BulkAddToCollectionComponent,
ChapterMetadataDetailComponent,
FileInfoComponent
FileInfoComponent,
BookmarkComponent,
],
imports: [
CommonModule,

View File

@ -56,7 +56,7 @@ canvas {
.overlay {
background-color: rgba(0,0,0,0.5);
backdrop-filter: blur(10px);
backdrop-filter: blur(10px); // BUG: This doesn't work on Firefox
color: white;
}