mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
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:
parent
04ffd1ef6f
commit
a1a6333f09
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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));
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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");
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
102
API/Data/MigrateBookmarks.cs
Normal file
102
API/Data/MigrateBookmarks.cs
Normal 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);
|
||||
|
||||
}
|
||||
}
|
1317
API/Data/Migrations/20211217013734_BookmarkRefactor.Designer.cs
generated
Normal file
1317
API/Data/Migrations/20211217013734_BookmarkRefactor.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
API/Data/Migrations/20211217013734_BookmarkRefactor.cs
Normal file
25
API/Data/Migrations/20211217013734_BookmarkRefactor.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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");
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -42,6 +42,9 @@ namespace API.Helpers.Converters
|
||||
case ServerSettingKey.BaseUrl:
|
||||
destination.BaseUrl = row.Value;
|
||||
break;
|
||||
case ServerSettingKey.BookmarkDirectory:
|
||||
destination.BookmarksDirectory = row.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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("._");
|
||||
}
|
||||
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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())
|
||||
|
@ -5,7 +5,7 @@
|
||||
"TokenKey": "super secret unguessable key",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Default": "Information",
|
||||
"Microsoft": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Error",
|
||||
"Hangfire": "Information",
|
||||
|
@ -4,4 +4,5 @@ export interface PageBookmark {
|
||||
seriesId: number;
|
||||
volumeId: number;
|
||||
chapterId: number;
|
||||
fileName: string;
|
||||
}
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -8,4 +8,5 @@ export interface ServerSettings {
|
||||
enableOpds: boolean;
|
||||
enableAuthentication: boolean;
|
||||
baseUrl: string;
|
||||
bookmarksDirectory: string;
|
||||
}
|
||||
|
@ -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> <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> <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> <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> <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> <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> <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>
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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(
|
||||
|
28
UI/Web/src/app/cards/bookmark/bookmark.component.html
Normal file
28
UI/Web/src/app/cards/bookmark/bookmark.component.html
Normal 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>
|
25
UI/Web/src/app/cards/bookmark/bookmark.component.scss
Normal file
25
UI/Web/src/app/cards/bookmark/bookmark.component.scss
Normal 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;
|
||||
}
|
43
UI/Web/src/app/cards/bookmark/bookmark.component.ts
Normal file
43
UI/Web/src/app/cards/bookmark/bookmark.component.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user