mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-31 20:24:27 -04:00
Lots of Bugfixes (#2977)
This commit is contained in:
parent
8c629695ef
commit
616ed7a75d
@ -200,6 +200,8 @@ public class ParsingTests
|
|||||||
[InlineData("카비타", "카비타")]
|
[InlineData("카비타", "카비타")]
|
||||||
[InlineData("06", "06")]
|
[InlineData("06", "06")]
|
||||||
[InlineData("", "")]
|
[InlineData("", "")]
|
||||||
|
[InlineData("不安の種+", "不安の種+")]
|
||||||
|
[InlineData("不安の種*", "不安の種*")]
|
||||||
public void NormalizeTest(string input, string expected)
|
public void NormalizeTest(string input, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, Normalize(input));
|
Assert.Equal(expected, Normalize(input));
|
||||||
|
@ -22,6 +22,7 @@ using API.Extensions;
|
|||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
|
using AutoMapper;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -42,6 +43,7 @@ public class OpdsController : BaseApiController
|
|||||||
private readonly ISeriesService _seriesService;
|
private readonly ISeriesService _seriesService;
|
||||||
private readonly IAccountService _accountService;
|
private readonly IAccountService _accountService;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
|
||||||
|
|
||||||
private readonly XmlSerializer _xmlSerializer;
|
private readonly XmlSerializer _xmlSerializer;
|
||||||
@ -78,7 +80,8 @@ public class OpdsController : BaseApiController
|
|||||||
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
|
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
|
||||||
IDirectoryService directoryService, ICacheService cacheService,
|
IDirectoryService directoryService, ICacheService cacheService,
|
||||||
IReaderService readerService, ISeriesService seriesService,
|
IReaderService readerService, ISeriesService seriesService,
|
||||||
IAccountService accountService, ILocalizationService localizationService)
|
IAccountService accountService, ILocalizationService localizationService,
|
||||||
|
IMapper mapper)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_downloadService = downloadService;
|
_downloadService = downloadService;
|
||||||
@ -88,6 +91,7 @@ public class OpdsController : BaseApiController
|
|||||||
_seriesService = seriesService;
|
_seriesService = seriesService;
|
||||||
_accountService = accountService;
|
_accountService = accountService;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
_mapper = mapper;
|
||||||
|
|
||||||
_xmlSerializer = new XmlSerializer(typeof(Feed));
|
_xmlSerializer = new XmlSerializer(typeof(Feed));
|
||||||
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
|
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
|
||||||
@ -289,7 +293,7 @@ public class OpdsController : BaseApiController
|
|||||||
{
|
{
|
||||||
var baseUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BaseUrl)).Value;
|
var baseUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BaseUrl)).Value;
|
||||||
var prefix = "/api/opds/";
|
var prefix = "/api/opds/";
|
||||||
if (!Configuration.DefaultBaseUrl.Equals(baseUrl))
|
if (!Configuration.DefaultBaseUrl.Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase))
|
||||||
{
|
{
|
||||||
// We need to update the Prefix to account for baseUrl
|
// We need to update the Prefix to account for baseUrl
|
||||||
prefix = baseUrl + "api/opds/";
|
prefix = baseUrl + "api/opds/";
|
||||||
@ -849,16 +853,15 @@ public class OpdsController : BaseApiController
|
|||||||
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||||
foreach (var volume in seriesDetail.Volumes)
|
foreach (var volume in seriesDetail.Volumes)
|
||||||
{
|
{
|
||||||
var chapters = (await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id))
|
var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files);
|
||||||
.OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast);
|
|
||||||
|
|
||||||
foreach (var chapterId in chapters.Select(c => c.Id))
|
foreach (var chapter in chapters)
|
||||||
{
|
{
|
||||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
var chapterId = chapter.Id;
|
||||||
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
|
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
||||||
foreach (var mangaFile in files)
|
foreach (var mangaFile in chapter.Files)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, mangaFile, series, chapterTest, apiKey, prefix, baseUrl));
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, mangaFile, series, chapterDto, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -867,20 +870,20 @@ public class OpdsController : BaseApiController
|
|||||||
foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial))
|
foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial))
|
||||||
{
|
{
|
||||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(storylineChapter.Id);
|
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(storylineChapter.Id);
|
||||||
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(storylineChapter.Id);
|
var chapterDto = _mapper.Map<ChapterDto>(storylineChapter);
|
||||||
foreach (var mangaFile in files)
|
foreach (var mangaFile in files)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl));
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterDto, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var special in seriesDetail.Specials)
|
foreach (var special in seriesDetail.Specials)
|
||||||
{
|
{
|
||||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id);
|
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id);
|
||||||
var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(special.Id);
|
var chapterDto = _mapper.Map<ChapterDto>(special);
|
||||||
foreach (var mangaFile in files)
|
foreach (var mangaFile in files)
|
||||||
{
|
{
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl));
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, mangaFile, series, chapterDto, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1127,14 +1130,15 @@ public class OpdsController : BaseApiController
|
|||||||
? string.Empty
|
? string.Empty
|
||||||
: $" Summary: {chapter.Summary}"),
|
: $" Summary: {chapter.Summary}"),
|
||||||
Format = mangaFile.Format.ToString(),
|
Format = mangaFile.Format.ToString(),
|
||||||
Links = new List<FeedLink>()
|
Links =
|
||||||
{
|
[
|
||||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
|
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
|
||||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
|
$"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
|
||||||
// We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
|
||||||
accLink,
|
$"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
|
||||||
await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix)
|
// We MUST include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
|
||||||
},
|
accLink
|
||||||
|
],
|
||||||
Content = new FeedEntryContent()
|
Content = new FeedEntryContent()
|
||||||
{
|
{
|
||||||
Text = fileType,
|
Text = fileType,
|
||||||
@ -1142,6 +1146,12 @@ public class OpdsController : BaseApiController
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var canPageStream = mangaFile.Extension != ".epub";
|
||||||
|
if (canPageStream)
|
||||||
|
{
|
||||||
|
entry.Links.Add(await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix));
|
||||||
|
}
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1162,7 +1172,7 @@ public class OpdsController : BaseApiController
|
|||||||
{
|
{
|
||||||
var userId = await GetUser(apiKey);
|
var userId = await GetUser(apiKey);
|
||||||
if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page"));
|
if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page"));
|
||||||
var chapter = await _cacheService.Ensure(chapterId);
|
var chapter = await _cacheService.Ensure(chapterId, true);
|
||||||
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "cache-file-find"));
|
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "cache-file-find"));
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -1188,10 +1198,9 @@ public class OpdsController : BaseApiController
|
|||||||
SeriesId = seriesId,
|
SeriesId = seriesId,
|
||||||
VolumeId = volumeId,
|
VolumeId = volumeId,
|
||||||
LibraryId =libraryId
|
LibraryId =libraryId
|
||||||
}, await GetUser(apiKey));
|
}, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return File(content, MimeTypeMap.GetMimeType(format));
|
return File(content, MimeTypeMap.GetMimeType(format));
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
@ -1223,8 +1232,7 @@ public class OpdsController : BaseApiController
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@ -1242,12 +1250,14 @@ public class OpdsController : BaseApiController
|
|||||||
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg",
|
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg",
|
||||||
$"{prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
|
$"{prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
|
||||||
link.TotalPages = mangaFile.Pages;
|
link.TotalPages = mangaFile.Pages;
|
||||||
|
link.IsPageStream = true;
|
||||||
|
|
||||||
if (progress != null)
|
if (progress != null)
|
||||||
{
|
{
|
||||||
link.LastRead = progress.PageNum;
|
link.LastRead = progress.PageNum;
|
||||||
link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601
|
link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601
|
||||||
}
|
}
|
||||||
link.IsPageStream = true;
|
|
||||||
return link;
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1272,20 +1282,22 @@ public class OpdsController : BaseApiController
|
|||||||
{
|
{
|
||||||
Title = title,
|
Title = title,
|
||||||
Icon = $"{prefix}{apiKey}/favicon",
|
Icon = $"{prefix}{apiKey}/favicon",
|
||||||
Links = new List<FeedLink>()
|
Links =
|
||||||
{
|
[
|
||||||
link,
|
link,
|
||||||
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}"),
|
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}"),
|
||||||
CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, $"{prefix}{apiKey}/search")
|
CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, $"{prefix}{apiKey}/search")
|
||||||
},
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeXml(Feed? feed)
|
private string SerializeXml(Feed? feed)
|
||||||
{
|
{
|
||||||
if (feed == null) return string.Empty;
|
if (feed == null) return string.Empty;
|
||||||
|
|
||||||
using var sm = new StringWriter();
|
using var sm = new StringWriter();
|
||||||
_xmlSerializer.Serialize(sm, feed);
|
_xmlSerializer.Serialize(sm, feed);
|
||||||
|
|
||||||
return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds
|
return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,8 +50,14 @@ public class SearchController : BaseApiController
|
|||||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, User.GetUserId()));
|
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, User.GetUserId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches against different entities in the system against a query string
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="queryString"></param>
|
||||||
|
/// <param name="includeChapterAndFiles">Include Chapter and Filenames in the entities. This can slow down the search on larger systems</param>
|
||||||
|
/// <returns></returns>
|
||||||
[HttpGet("search")]
|
[HttpGet("search")]
|
||||||
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
|
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString, [FromQuery] bool includeChapterAndFiles = true)
|
||||||
{
|
{
|
||||||
queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString);
|
queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString);
|
||||||
|
|
||||||
@ -63,7 +69,7 @@ public class SearchController : BaseApiController
|
|||||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||||
|
|
||||||
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin,
|
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin,
|
||||||
libraries, queryString);
|
libraries, queryString, includeChapterAndFiles);
|
||||||
|
|
||||||
return Ok(series);
|
return Ok(series);
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,7 @@ public interface IChapterRepository
|
|||||||
Task<ChapterDto?> GetChapterDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
|
Task<ChapterDto?> GetChapterDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
|
||||||
Task<ChapterMetadataDto?> GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
|
Task<ChapterMetadataDto?> GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files);
|
||||||
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
||||||
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
|
Task<IList<Chapter>> GetChaptersAsync(int volumeId, ChapterIncludes includes = ChapterIncludes.None);
|
||||||
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
||||||
Task<string?> GetChapterCoverImageAsync(int chapterId);
|
Task<string?> GetChapterCoverImageAsync(int chapterId);
|
||||||
Task<IList<string>> GetAllCoverImagesAsync();
|
Task<IList<string>> GetAllCoverImagesAsync();
|
||||||
@ -184,10 +184,11 @@ public class ChapterRepository : IChapterRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="volumeId"></param>
|
/// <param name="volumeId"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<IList<Chapter>> GetChaptersAsync(int volumeId)
|
public async Task<IList<Chapter>> GetChaptersAsync(int volumeId, ChapterIncludes includes = ChapterIncludes.None)
|
||||||
{
|
{
|
||||||
return await _context.Chapter
|
return await _context.Chapter
|
||||||
.Where(c => c.VolumeId == volumeId)
|
.Where(c => c.VolumeId == volumeId)
|
||||||
|
.Includes(includes)
|
||||||
.OrderBy(c => c.SortOrder)
|
.OrderBy(c => c.SortOrder)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
@ -91,8 +91,9 @@ public interface ISeriesRepository
|
|||||||
/// <param name="isAdmin"></param>
|
/// <param name="isAdmin"></param>
|
||||||
/// <param name="libraryIds"></param>
|
/// <param name="libraryIds"></param>
|
||||||
/// <param name="searchQuery"></param>
|
/// <param name="searchQuery"></param>
|
||||||
|
/// <param name="includeChapterAndFiles">Includes Files in the Search</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery);
|
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery, bool includeChapterAndFiles = true);
|
||||||
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None);
|
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None);
|
||||||
Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId);
|
Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId);
|
||||||
Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
|
Task<Series?> GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata);
|
||||||
@ -353,7 +354,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
return [libraryId];
|
return [libraryId];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery)
|
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery, bool includeChapterAndFiles = true)
|
||||||
{
|
{
|
||||||
const int maxRecords = 15;
|
const int maxRecords = 15;
|
||||||
var result = new SearchResultGroupDto();
|
var result = new SearchResultGroupDto();
|
||||||
@ -452,42 +453,45 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var fileIds = _context.Series
|
result.Files = new List<MangaFileDto>();
|
||||||
.Where(s => seriesIds.Contains(s.Id))
|
result.Chapters = new List<ChapterDto>();
|
||||||
.AsSplitQuery()
|
|
||||||
.SelectMany(s => s.Volumes)
|
|
||||||
.SelectMany(v => v.Chapters)
|
|
||||||
.SelectMany(c => c.Files.Select(f => f.Id));
|
|
||||||
|
|
||||||
// Need to check if an admin
|
|
||||||
var user = await _context.AppUser.FirstAsync(u => u.Id == userId);
|
if (includeChapterAndFiles)
|
||||||
if (await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
|
|
||||||
{
|
{
|
||||||
result.Files = await _context.MangaFile
|
var fileIds = _context.Series
|
||||||
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
|
.Where(s => seriesIds.Contains(s.Id))
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.OrderBy(f => f.FilePath)
|
.SelectMany(s => s.Volumes)
|
||||||
|
.SelectMany(v => v.Chapters)
|
||||||
|
.SelectMany(c => c.Files.Select(f => f.Id));
|
||||||
|
|
||||||
|
// Need to check if an admin
|
||||||
|
var user = await _context.AppUser.FirstAsync(u => u.Id == userId);
|
||||||
|
if (await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
|
||||||
|
{
|
||||||
|
result.Files = await _context.MangaFile
|
||||||
|
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
|
||||||
|
.AsSplitQuery()
|
||||||
|
.OrderBy(f => f.FilePath)
|
||||||
|
.Take(maxRecords)
|
||||||
|
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Chapters = await _context.Chapter
|
||||||
|
.Include(c => c.Files)
|
||||||
|
.Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%")
|
||||||
|
|| EF.Functions.Like(c.ISBN, $"%{searchQuery}%")
|
||||||
|
|| EF.Functions.Like(c.Range, $"%{searchQuery}%")
|
||||||
|
)
|
||||||
|
.Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
|
||||||
|
.AsSplitQuery()
|
||||||
|
.OrderBy(c => c.TitleName)
|
||||||
.Take(maxRecords)
|
.Take(maxRecords)
|
||||||
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
result.Files = new List<MangaFileDto>();
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Chapters = await _context.Chapter
|
|
||||||
.Include(c => c.Files)
|
|
||||||
.Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%")
|
|
||||||
|| EF.Functions.Like(c.ISBN, $"%{searchQuery}%")
|
|
||||||
|| EF.Functions.Like(c.Range, $"%{searchQuery}%")
|
|
||||||
)
|
|
||||||
.Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
|
|
||||||
.AsSplitQuery()
|
|
||||||
.OrderBy(c => c.TitleName)
|
|
||||||
.Take(maxRecords)
|
|
||||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -2094,6 +2098,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
LastScanned = s.LastFolderScanned,
|
LastScanned = s.LastFolderScanned,
|
||||||
SeriesName = s.Name,
|
SeriesName = s.Name,
|
||||||
FolderPath = s.FolderPath,
|
FolderPath = s.FolderPath,
|
||||||
|
LowestFolderPath = s.LowestFolderPath,
|
||||||
Format = s.Format,
|
Format = s.Format,
|
||||||
LibraryRoots = s.Library.Folders.Select(f => f.Path)
|
LibraryRoots = s.Library.Folders.Select(f => f.Path)
|
||||||
}).ToListAsync();
|
}).ToListAsync();
|
||||||
@ -2101,7 +2106,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
var map = new Dictionary<string, IList<SeriesModified>>();
|
var map = new Dictionary<string, IList<SeriesModified>>();
|
||||||
foreach (var series in info)
|
foreach (var series in info)
|
||||||
{
|
{
|
||||||
if (series.FolderPath == null) continue;
|
if (string.IsNullOrEmpty(series.FolderPath)) continue;
|
||||||
if (!map.TryGetValue(series.FolderPath, out var value))
|
if (!map.TryGetValue(series.FolderPath, out var value))
|
||||||
{
|
{
|
||||||
map.Add(series.FolderPath, new List<SeriesModified>()
|
map.Add(series.FolderPath, new List<SeriesModified>()
|
||||||
@ -2113,6 +2118,20 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
{
|
{
|
||||||
value.Add(series);
|
value.Add(series);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(series.LowestFolderPath)) continue;
|
||||||
|
if (!map.TryGetValue(series.LowestFolderPath, out var value2))
|
||||||
|
{
|
||||||
|
map.Add(series.LowestFolderPath, new List<SeriesModified>()
|
||||||
|
{
|
||||||
|
series
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
value2.Add(series);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
|
@ -7,17 +7,23 @@ namespace API.Extensions;
|
|||||||
|
|
||||||
public static class ClaimsPrincipalExtensions
|
public static class ClaimsPrincipalExtensions
|
||||||
{
|
{
|
||||||
|
private const string NotAuthenticatedMessage = "User is not authenticated";
|
||||||
|
/// <summary>
|
||||||
|
/// Get's the authenticated user's username
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Warning! Username's can contain .. and /, do not use folders or filenames explicitly with the Username</remarks>
|
||||||
|
/// <param name="user"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
/// <exception cref="KavitaException"></exception>
|
||||||
public static string GetUsername(this ClaimsPrincipal user)
|
public static string GetUsername(this ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
var userClaim = user.FindFirst(JwtRegisteredClaimNames.Name);
|
var userClaim = user.FindFirst(JwtRegisteredClaimNames.Name) ?? throw new KavitaException(NotAuthenticatedMessage);
|
||||||
if (userClaim == null) throw new KavitaException("User is not authenticated");
|
|
||||||
return userClaim.Value;
|
return userClaim.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int GetUserId(this ClaimsPrincipal user)
|
public static int GetUserId(this ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
var userClaim = user.FindFirst(ClaimTypes.NameIdentifier);
|
var userClaim = user.FindFirst(ClaimTypes.NameIdentifier) ?? throw new KavitaException(NotAuthenticatedMessage);
|
||||||
if (userClaim == null) throw new KavitaException("User is not authenticated");
|
|
||||||
return int.Parse(userClaim.Value);
|
return int.Parse(userClaim.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -847,7 +847,7 @@ public class BookService : IBookService
|
|||||||
Filename = Path.GetFileName(filePath),
|
Filename = Path.GetFileName(filePath),
|
||||||
Title = specialName?.Trim() ?? string.Empty,
|
Title = specialName?.Trim() ?? string.Empty,
|
||||||
FullFilePath = Parser.NormalizePath(filePath),
|
FullFilePath = Parser.NormalizePath(filePath),
|
||||||
IsSpecial = false,
|
IsSpecial = Parser.HasSpecialMarker(filePath),
|
||||||
Series = series.Trim(),
|
Series = series.Trim(),
|
||||||
SeriesSort = series.Trim(),
|
SeriesSort = series.Trim(),
|
||||||
Volumes = seriesIndex
|
Volumes = seriesIndex
|
||||||
@ -869,7 +869,7 @@ public class BookService : IBookService
|
|||||||
Filename = Path.GetFileName(filePath),
|
Filename = Path.GetFileName(filePath),
|
||||||
Title = epubBook.Title.Trim(),
|
Title = epubBook.Title.Trim(),
|
||||||
FullFilePath = Parser.NormalizePath(filePath),
|
FullFilePath = Parser.NormalizePath(filePath),
|
||||||
IsSpecial = false,
|
IsSpecial = Parser.HasSpecialMarker(filePath),
|
||||||
Series = epubBook.Title.Trim(),
|
Series = epubBook.Title.Trim(),
|
||||||
Volumes = Parser.LooseLeafVolume,
|
Volumes = Parser.LooseLeafVolume,
|
||||||
};
|
};
|
||||||
|
@ -322,7 +322,7 @@ public class CacheService : ICacheService
|
|||||||
var path = GetCachePath(chapterId);
|
var path = GetCachePath(chapterId);
|
||||||
// NOTE: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access
|
// NOTE: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access
|
||||||
var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions)
|
var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions)
|
||||||
.OrderByNatural(Path.GetFileNameWithoutExtension)
|
//.OrderByNatural(Path.GetFileNameWithoutExtension) // This is already done in GetPageFromFiles
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return GetPageFromFiles(files, page);
|
return GetPageFromFiles(files, page);
|
||||||
|
@ -228,7 +228,7 @@ public class ScrobblingService : IScrobblingService
|
|||||||
LibraryId = series.LibraryId,
|
LibraryId = series.LibraryId,
|
||||||
ScrobbleEventType = ScrobbleEventType.Review,
|
ScrobbleEventType = ScrobbleEventType.Review,
|
||||||
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
||||||
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite),
|
MalId = GetMalId(series),
|
||||||
AppUserId = userId,
|
AppUserId = userId,
|
||||||
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||||
ReviewBody = reviewBody,
|
ReviewBody = reviewBody,
|
||||||
@ -250,7 +250,7 @@ public class ScrobblingService : IScrobblingService
|
|||||||
{
|
{
|
||||||
if (!await _licenseService.HasActiveLicense()) return;
|
if (!await _licenseService.HasActiveLicense()) return;
|
||||||
|
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
|
||||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||||
|
|
||||||
_logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name);
|
_logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name);
|
||||||
@ -274,22 +274,34 @@ public class ScrobblingService : IScrobblingService
|
|||||||
SeriesId = series.Id,
|
SeriesId = series.Id,
|
||||||
LibraryId = series.LibraryId,
|
LibraryId = series.LibraryId,
|
||||||
ScrobbleEventType = ScrobbleEventType.ScoreUpdated,
|
ScrobbleEventType = ScrobbleEventType.ScoreUpdated,
|
||||||
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite), // TODO: We can get this also from ExternalSeriesMetadata
|
AniListId = GetAniListId(series),
|
||||||
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite),
|
MalId = GetMalId(series),
|
||||||
AppUserId = userId,
|
AppUserId = userId,
|
||||||
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||||
Rating = rating
|
Rating = rating
|
||||||
};
|
};
|
||||||
_unitOfWork.ScrobbleRepository.Attach(evt);
|
_unitOfWork.ScrobbleRepository.Attach(evt);
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
_logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId} ", series.Name, userId);
|
_logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId}", series.Name, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long? GetMalId(Series series)
|
||||||
|
{
|
||||||
|
var malId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite);
|
||||||
|
return malId ?? series.ExternalSeriesMetadata.MalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? GetAniListId(Series series)
|
||||||
|
{
|
||||||
|
var aniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite);
|
||||||
|
return aniListId ?? series.ExternalSeriesMetadata.AniListId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ScrobbleReadingUpdate(int userId, int seriesId)
|
public async Task ScrobbleReadingUpdate(int userId, int seriesId)
|
||||||
{
|
{
|
||||||
if (!await _licenseService.HasActiveLicense()) return;
|
if (!await _licenseService.HasActiveLicense()) return;
|
||||||
|
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
|
||||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||||
|
|
||||||
_logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name);
|
_logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name);
|
||||||
@ -321,8 +333,8 @@ public class ScrobblingService : IScrobblingService
|
|||||||
SeriesId = series.Id,
|
SeriesId = series.Id,
|
||||||
LibraryId = series.LibraryId,
|
LibraryId = series.LibraryId,
|
||||||
ScrobbleEventType = ScrobbleEventType.ChapterRead,
|
ScrobbleEventType = ScrobbleEventType.ChapterRead,
|
||||||
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
AniListId = GetAniListId(series),
|
||||||
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite),
|
MalId = GetMalId(series),
|
||||||
AppUserId = userId,
|
AppUserId = userId,
|
||||||
VolumeNumber =
|
VolumeNumber =
|
||||||
(int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId),
|
(int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId),
|
||||||
@ -345,7 +357,7 @@ public class ScrobblingService : IScrobblingService
|
|||||||
{
|
{
|
||||||
if (!await _licenseService.HasActiveLicense()) return;
|
if (!await _licenseService.HasActiveLicense()) return;
|
||||||
|
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata);
|
||||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||||
|
|
||||||
_logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name);
|
_logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name);
|
||||||
@ -360,8 +372,8 @@ public class ScrobblingService : IScrobblingService
|
|||||||
SeriesId = series.Id,
|
SeriesId = series.Id,
|
||||||
LibraryId = series.LibraryId,
|
LibraryId = series.LibraryId,
|
||||||
ScrobbleEventType = onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead,
|
ScrobbleEventType = onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead,
|
||||||
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
AniListId = GetAniListId(series),
|
||||||
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite),
|
MalId = GetMalId(series),
|
||||||
AppUserId = userId,
|
AppUserId = userId,
|
||||||
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||||
};
|
};
|
||||||
|
@ -394,7 +394,7 @@ public class ReadingListService : IReadingListService
|
|||||||
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();
|
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();
|
||||||
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds, ChapterIncludes.Volumes))
|
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds, ChapterIncludes.Volumes))
|
||||||
.OrderBy(c => c.Volume.MinNumber)
|
.OrderBy(c => c.Volume.MinNumber)
|
||||||
.ThenBy(x => x.MinNumber, _chapterSortComparerForInChapterSorting)
|
.ThenBy(x => x.SortOrder)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var index = readingList.Items.Count == 0 ? 0 : lastOrder + 1;
|
var index = readingList.Items.Count == 0 ? 0 : lastOrder + 1;
|
||||||
|
@ -79,6 +79,7 @@ public class ScannedSeriesResult
|
|||||||
public class SeriesModified
|
public class SeriesModified
|
||||||
{
|
{
|
||||||
public required string? FolderPath { get; set; }
|
public required string? FolderPath { get; set; }
|
||||||
|
public required string? LowestFolderPath { get; set; }
|
||||||
public required string SeriesName { get; set; }
|
public required string SeriesName { get; set; }
|
||||||
public DateTime LastScanned { get; set; }
|
public DateTime LastScanned { get; set; }
|
||||||
public MangaFormat Format { get; set; }
|
public MangaFormat Format { get; set; }
|
||||||
@ -151,16 +152,28 @@ public class ParseScannedFiles
|
|||||||
HasChanged = false
|
HasChanged = false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
else if (seriesPaths.TryGetValue(normalizedPath, out var series) && series.All(s => !string.IsNullOrEmpty(s.LowestFolderPath)))
|
||||||
|
{
|
||||||
|
// If there are multiple series inside this path, let's check each of them to see which was modified and only scan those
|
||||||
|
// This is very helpful for ComicVine libraries by Publisher
|
||||||
|
foreach (var seriesModified in series)
|
||||||
|
{
|
||||||
|
if (HasSeriesFolderNotChangedSinceLastScan(seriesModified, seriesModified.LowestFolderPath!))
|
||||||
|
{
|
||||||
|
result.Add(CreateScanResult(directory, folderPath, false, ArraySegment<string>.Empty));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Add(CreateScanResult(directory, folderPath, true,
|
||||||
|
_directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication
|
// For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication
|
||||||
result.Add(new ScanResult()
|
result.Add(CreateScanResult(directory, folderPath, true,
|
||||||
{
|
_directoryService.ScanFiles(directory, fileExtensions)));
|
||||||
Files = _directoryService.ScanFiles(directory, fileExtensions, matcher),
|
|
||||||
Folder = directory,
|
|
||||||
LibraryRoot = folderPath,
|
|
||||||
HasChanged = true
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,26 +188,30 @@ public class ParseScannedFiles
|
|||||||
|
|
||||||
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck))
|
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck))
|
||||||
{
|
{
|
||||||
result.Add(new ScanResult()
|
result.Add(CreateScanResult(folderPath, libraryRoot, false, ArraySegment<string>.Empty));
|
||||||
{
|
}
|
||||||
Files = ArraySegment<string>.Empty,
|
else
|
||||||
Folder = folderPath,
|
{
|
||||||
LibraryRoot = libraryRoot,
|
result.Add(CreateScanResult(folderPath, libraryRoot, true,
|
||||||
HasChanged = false
|
_directoryService.ScanFiles(folderPath, fileExtensions)));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Add(new ScanResult()
|
|
||||||
{
|
|
||||||
Files = _directoryService.ScanFiles(folderPath, fileExtensions),
|
|
||||||
Folder = folderPath,
|
|
||||||
LibraryRoot = libraryRoot,
|
|
||||||
HasChanged = true
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ScanResult CreateScanResult(string folderPath, string libraryRoot, bool hasChanged,
|
||||||
|
IList<string> files)
|
||||||
|
{
|
||||||
|
return new ScanResult()
|
||||||
|
{
|
||||||
|
Files = files,
|
||||||
|
Folder = folderPath,
|
||||||
|
LibraryRoot = libraryRoot,
|
||||||
|
HasChanged = hasChanged
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to either add a new instance of a series mapping to the _scannedSeries bag or adds to an existing.
|
/// Attempts to either add a new instance of a series mapping to the _scannedSeries bag or adds to an existing.
|
||||||
@ -535,10 +552,29 @@ public class ParseScannedFiles
|
|||||||
{
|
{
|
||||||
if (forceCheck) return false;
|
if (forceCheck) return false;
|
||||||
|
|
||||||
return seriesPaths.ContainsKey(normalizedFolder) && seriesPaths[normalizedFolder].All(f => f.LastScanned.Truncate(TimeSpan.TicksPerSecond) >=
|
if (seriesPaths.TryGetValue(normalizedFolder, out var v))
|
||||||
_directoryService.GetLastWriteTime(normalizedFolder).Truncate(TimeSpan.TicksPerSecond));
|
{
|
||||||
|
return HasAllSeriesFolderNotChangedSinceLastScan(v, normalizedFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool HasAllSeriesFolderNotChangedSinceLastScan(IList<SeriesModified> seriesFolders,
|
||||||
|
string normalizedFolder)
|
||||||
|
{
|
||||||
|
return seriesFolders.All(f => HasSeriesFolderNotChangedSinceLastScan(f, normalizedFolder));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasSeriesFolderNotChangedSinceLastScan(SeriesModified seriesModified, string normalizedFolder)
|
||||||
|
{
|
||||||
|
return seriesModified.LastScanned.Truncate(TimeSpan.TicksPerSecond) >=
|
||||||
|
_directoryService.GetLastWriteTime(normalizedFolder)
|
||||||
|
.Truncate(TimeSpan.TicksPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if there are any ParserInfos that have a Series that matches the LocalizedSeries field in any other info. If so,
|
/// Checks if there are any ParserInfos that have a Series that matches the LocalizedSeries field in any other info. If so,
|
||||||
/// rewrites the infos with series name instead of the localized name, so they stack.
|
/// rewrites the infos with series name instead of the localized name, so they stack.
|
||||||
|
@ -12,8 +12,14 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
|
|||||||
|
|
||||||
info.ComicInfo = comicInfo;
|
info.ComicInfo = comicInfo;
|
||||||
|
|
||||||
|
// We need a special piece of code to override the Series IF there is a special marker in the filename for epub files
|
||||||
|
if (info.IsSpecial && info.Volumes == "0" && info.ComicInfo.Series != info.Series)
|
||||||
|
{
|
||||||
|
info.Series = info.ComicInfo.Series;
|
||||||
|
}
|
||||||
|
|
||||||
// This catches when original library type is Manga/Comic and when parsing with non
|
// This catches when original library type is Manga/Comic and when parsing with non
|
||||||
if (Parser.ParseVolume(info.Series, type) != Parser.LooseLeafVolume) // Shouldn't this be info.Volume != DefaultVolume?
|
if (Parser.ParseVolume(info.Series, type) != Parser.LooseLeafVolume)
|
||||||
{
|
{
|
||||||
var hasVolumeInTitle = !Parser.ParseVolume(info.Title, type)
|
var hasVolumeInTitle = !Parser.ParseVolume(info.Title, type)
|
||||||
.Equals(Parser.LooseLeafVolume);
|
.Equals(Parser.LooseLeafVolume);
|
||||||
|
@ -103,7 +103,7 @@ public static class Parser
|
|||||||
private static readonly Regex CoverImageRegex = new Regex(@"(?<![[a-z]\d])(?:!?)(?<!back)(?<!back_)(?<!back-)(cover|folder)(?![\w\d])",
|
private static readonly Regex CoverImageRegex = new Regex(@"(?<![[a-z]\d])(?:!?)(?<!back)(?<!back_)(?<!back-)(cover|folder)(?![\w\d])",
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
|
|
||||||
private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!]",
|
private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!\*]",
|
||||||
MatchOptions, RegexTimeout);
|
MatchOptions, RegexTimeout);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -710,6 +710,12 @@ public class ProcessSeries : IProcessSeries
|
|||||||
chapter.SortOrder = info.IssueOrder;
|
chapter.SortOrder = info.IssueOrder;
|
||||||
}
|
}
|
||||||
chapter.Range = chapter.GetNumberTitle();
|
chapter.Range = chapter.GetNumberTitle();
|
||||||
|
if (float.TryParse(chapter.Title, out var _))
|
||||||
|
{
|
||||||
|
// If we have float based chapters, first scan can have the chapter formatted as Chapter 0.2 - .2 as the title is wrong.
|
||||||
|
chapter.Title = chapter.GetNumberTitle();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -358,7 +358,14 @@ public class Startup
|
|||||||
|
|
||||||
app.UseStaticFiles(new StaticFileOptions
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
{
|
{
|
||||||
ContentTypeProvider = new FileExtensionContentTypeProvider(),
|
// bcmap files needed for PDF reader localizations (https://github.com/Kareadita/Kavita/issues/2970)
|
||||||
|
ContentTypeProvider = new FileExtensionContentTypeProvider
|
||||||
|
{
|
||||||
|
Mappings =
|
||||||
|
{
|
||||||
|
[".bcmap"] = "application/octet-stream"
|
||||||
|
}
|
||||||
|
},
|
||||||
HttpsCompression = HttpsCompressionMode.Compress,
|
HttpsCompression = HttpsCompressionMode.Compress,
|
||||||
OnPrepareResponse = ctx =>
|
OnPrepareResponse = ctx =>
|
||||||
{
|
{
|
||||||
|
@ -14,11 +14,11 @@ export class SearchService {
|
|||||||
|
|
||||||
constructor(private httpClient: HttpClient) { }
|
constructor(private httpClient: HttpClient) { }
|
||||||
|
|
||||||
search(term: string) {
|
search(term: string, includeChapterAndFiles: boolean = false) {
|
||||||
if (term === '') {
|
if (term === '') {
|
||||||
return of(new SearchResultGroup());
|
return of(new SearchResultGroup());
|
||||||
}
|
}
|
||||||
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'search/search?queryString=' + encodeURIComponent(term));
|
return this.httpClient.get<SearchResultGroup>(this.baseUrl + `search/search?includeChapterAndFiles=${includeChapterAndFiles}&queryString=${encodeURIComponent(term)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSeriesForMangaFile(mangaFileId: number) {
|
getSeriesForMangaFile(mangaFileId: number) {
|
||||||
|
@ -11,7 +11,6 @@ import {forkJoin} from "rxjs";
|
|||||||
import {ToastrService} from "ngx-toastr";
|
import {ToastrService} from "ngx-toastr";
|
||||||
import {DecimalPipe} from "@angular/common";
|
import {DecimalPipe} from "@angular/common";
|
||||||
import {LoadingComponent} from "../../../shared/loading/loading.component";
|
import {LoadingComponent} from "../../../shared/loading/loading.component";
|
||||||
import {AccountService} from "../../../_services/account.service";
|
|
||||||
import {ConfirmService} from "../../../shared/confirm.service";
|
import {ConfirmService} from "../../../shared/confirm.service";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -5,126 +5,166 @@
|
|||||||
<input #input [id]="id" type="text" inputmode="search" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
|
<input #input [id]="id" type="text" inputmode="search" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
|
||||||
aria-haspopup="listbox" aria-owns="dropdown"
|
aria-haspopup="listbox" aria-owns="dropdown"
|
||||||
[attr.aria-expanded]="hasFocus && hasData"
|
[attr.aria-expanded]="hasFocus && hasData"
|
||||||
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)" role="search"
|
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)" role="searchbox"
|
||||||
>
|
>
|
||||||
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoading">
|
@if (searchTerm.length > 0) {
|
||||||
<span class="visually-hidden">{{t('loading')}}</span>
|
@if (isLoading) {
|
||||||
</div>
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()" *ngIf="typeaheadForm.get('typeahead')?.value.length > 0"></button>
|
<span class="visually-hidden">{{t('loading')}}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown" *ngIf="hasFocus">
|
@if (hasFocus) {
|
||||||
<ul class="list-group" role="listbox" id="dropdown">
|
<div class="dropdown">
|
||||||
<ng-container *ngIf="seriesTemplate !== undefined && groupedData.series.length > 0">
|
<ul class="list-group" role="listbox" id="dropdown">
|
||||||
<li class="list-group-item section-header"><h5 id="series-group">Series</h5></li>
|
|
||||||
<ul class="list-group results" role="group" aria-describedby="series-group">
|
|
||||||
<li *ngFor="let option of groupedData.series; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
|
||||||
class="list-group-item" aria-labelledby="series-group" role="option">
|
|
||||||
<ng-container [ngTemplateOutlet]="seriesTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngIf="collectionTemplate !== undefined && groupedData.collections.length > 0">
|
@if (seriesTemplate !== undefined && groupedData.series.length > 0) {
|
||||||
<li class="list-group-item section-header"><h5>{{t('collections')}}</h5></li>
|
<li class="list-group-item section-header"><h5 id="series-group">Series</h5></li>
|
||||||
<ul class="list-group results">
|
<ul class="list-group results" role="group" aria-describedby="series-group">
|
||||||
<li *ngFor="let option of groupedData.collections; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
@for(option of groupedData.series; track option; let index = $index) {
|
||||||
class="list-group-item" role="option">
|
<li (click)="handleResultClick(option)" tabindex="0"
|
||||||
<ng-container [ngTemplateOutlet]="collectionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
class="list-group-item" aria-labelledby="series-group" role="option">
|
||||||
</li>
|
<ng-container [ngTemplateOutlet]="seriesTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||||
</ul>
|
</li>
|
||||||
</ng-container>
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container *ngIf="readingListTemplate !== undefined && groupedData.readingLists.length > 0">
|
|
||||||
<li class="list-group-item section-header"><h5>{{t('reading-lists')}}</h5></li>
|
|
||||||
<ul class="list-group results">
|
|
||||||
<li *ngFor="let option of groupedData.readingLists; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
|
||||||
class="list-group-item" role="option">
|
|
||||||
<ng-container [ngTemplateOutlet]="readingListTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngIf="bookmarkTemplate !== undefined && groupedData.bookmarks.length > 0">
|
@if (collectionTemplate !== undefined && groupedData.collections.length > 0) {
|
||||||
<li class="list-group-item section-header"><h5>{{t('bookmarks')}}</h5></li>
|
<li class="list-group-item section-header"><h5>{{t('collections')}}</h5></li>
|
||||||
<ul class="list-group results">
|
<ul class="list-group results">
|
||||||
<li *ngFor="let option of groupedData.bookmarks; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
@for(option of groupedData.collections; track option; let index = $index) {
|
||||||
class="list-group-item" role="option">
|
<li (click)="handleResultClick(option)" tabindex="0"
|
||||||
<ng-container [ngTemplateOutlet]="bookmarkTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
class="list-group-item" role="option">
|
||||||
</li>
|
<ng-container [ngTemplateOutlet]="collectionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||||
</ul>
|
</li>
|
||||||
</ng-container>
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container *ngIf="libraryTemplate !== undefined && groupedData.libraries.length > 0">
|
|
||||||
<li class="list-group-item section-header"><h5 id="libraries-group">{{t('libraries')}}</h5></li>
|
|
||||||
<ul class="list-group results" role="group" aria-describedby="libraries-group">
|
|
||||||
<li *ngFor="let option of groupedData.libraries; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
|
||||||
class="list-group-item" aria-labelledby="libraries-group" role="option">
|
|
||||||
<ng-container [ngTemplateOutlet]="libraryTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngIf="genreTemplate !== undefined && groupedData.genres.length > 0">
|
@if (readingListTemplate !== undefined && groupedData.readingLists.length > 0) {
|
||||||
<li class="list-group-item section-header"><h5>{{t('genres')}}</h5></li>
|
<li class="list-group-item section-header"><h5>{{t('reading-lists')}}</h5></li>
|
||||||
<ul class="list-group results">
|
<ul class="list-group results">
|
||||||
<li *ngFor="let option of groupedData.genres; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
@for(option of groupedData.readingLists; track option; let index = $index) {
|
||||||
class="list-group-item" role="option">
|
<li (click)="handleResultClick(option)" tabindex="0"
|
||||||
<ng-container [ngTemplateOutlet]="genreTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
class="list-group-item" role="option">
|
||||||
</li>
|
<ng-container [ngTemplateOutlet]="readingListTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||||
</ul>
|
</li>
|
||||||
</ng-container>
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container *ngIf="tagTemplate !== undefined && groupedData.tags.length > 0">
|
@if (bookmarkTemplate !== undefined && groupedData.bookmarks.length > 0) {
|
||||||
<li class="list-group-item section-header"><h5>{{t('tags')}}</h5></li>
|
<li class="list-group-item section-header"><h5>{{t('bookmarks')}}</h5></li>
|
||||||
<ul class="list-group results">
|
<ul class="list-group results">
|
||||||
<li *ngFor="let option of groupedData.tags; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
@for(option of groupedData.bookmarks; track option; let index = $index) {
|
||||||
class="list-group-item" role="option">
|
<li (click)="handleResultClick(option)" tabindex="0"
|
||||||
<ng-container [ngTemplateOutlet]="tagTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
class="list-group-item" role="option">
|
||||||
</li>
|
<ng-container [ngTemplateOutlet]="bookmarkTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||||
</ul>
|
</li>
|
||||||
</ng-container>
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container *ngIf="personTemplate !== undefined && groupedData.persons.length > 0">
|
|
||||||
<li class="list-group-item section-header"><h5>{{t('people')}}</h5></li>
|
|
||||||
<ul class="list-group results">
|
|
||||||
<li *ngFor="let option of groupedData.persons; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
|
||||||
class="list-group-item" role="option">
|
|
||||||
<ng-container [ngTemplateOutlet]="personTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngIf="chapterTemplate !== undefined && groupedData.chapters.length > 0">
|
@if (libraryTemplate !== undefined && groupedData.libraries.length > 0) {
|
||||||
<li class="list-group-item section-header"><h5>{{t('chapters')}}</h5></li>
|
<li class="list-group-item section-header"><h5 id="libraries-group">{{t('libraries')}}</h5></li>
|
||||||
<ul class="list-group results">
|
<ul class="list-group results" role="group" aria-describedby="libraries-group">
|
||||||
<li *ngFor="let option of groupedData.chapters; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
@for(option of groupedData.libraries; track option; let index = $index) {
|
||||||
class="list-group-item" role="option">
|
<li (click)="handleResultClick(option)" tabindex="0"
|
||||||
<ng-container [ngTemplateOutlet]="chapterTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
class="list-group-item" aria-labelledby="libraries-group" role="option">
|
||||||
</li>
|
<ng-container [ngTemplateOutlet]="libraryTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||||
</ul>
|
</li>
|
||||||
</ng-container>
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container *ngIf="fileTemplate !== undefined && groupedData.files.length > 0">
|
@if (genreTemplate !== undefined && groupedData.genres.length > 0) {
|
||||||
<li class="list-group-item section-header"><h5>{{t('files')}}</h5></li>
|
<li class="list-group-item section-header"><h5>{{t('genres')}}</h5></li>
|
||||||
<ul class="list-group results">
|
<ul class="list-group results">
|
||||||
<li *ngFor="let option of groupedData.files; let index = index;" (click)="handleResultlick(option)" tabindex="0"
|
@for(option of groupedData.genres; track option; let index = $index) {
|
||||||
class="list-group-item" role="option">
|
<li (click)="handleResultClick(option)" tabindex="0"
|
||||||
<ng-container [ngTemplateOutlet]="fileTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
class="list-group-item" role="option">
|
||||||
</li>
|
<ng-container [ngTemplateOutlet]="genreTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||||
</ul>
|
</li>
|
||||||
</ng-container>
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container *ngIf="!hasData && searchTerm.length > 0">
|
@if (tagTemplate !== undefined && groupedData.tags.length > 0) {
|
||||||
<ul class="list-group results">
|
<li class="list-group-item section-header"><h5>{{t('tags')}}</h5></li>
|
||||||
<li class="list-group-item">
|
<ul class="list-group results">
|
||||||
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>
|
@for(option of groupedData.tags; track option; let index = $index) {
|
||||||
</li>
|
<li (click)="handleResultClick(option)" tabindex="0"
|
||||||
</ul>
|
class="list-group-item" role="option">
|
||||||
|
<ng-container [ngTemplateOutlet]="tagTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
</ng-container>
|
@if (personTemplate !== undefined && groupedData.persons.length > 0) {
|
||||||
</ul>
|
<li class="list-group-item section-header"><h5>{{t('people')}}</h5></li>
|
||||||
</div>
|
<ul class="list-group results">
|
||||||
|
@for(option of groupedData.persons; track option; let index = $index) {
|
||||||
|
<li (click)="handleResultClick(option)" tabindex="0"
|
||||||
|
class="list-group-item" role="option">
|
||||||
|
<ng-container [ngTemplateOutlet]="personTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (chapterTemplate !== undefined && groupedData.chapters.length > 0) {
|
||||||
|
<li class="list-group-item section-header"><h5>{{t('chapters')}}</h5></li>
|
||||||
|
<ul class="list-group results">
|
||||||
|
@for(option of groupedData.chapters; track option; let index = $index) {
|
||||||
|
<li (click)="handleResultClick(option)" tabindex="0"
|
||||||
|
class="list-group-item" role="option">
|
||||||
|
<ng-container [ngTemplateOutlet]="chapterTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (fileTemplate !== undefined && groupedData.files.length > 0) {
|
||||||
|
<li class="list-group-item section-header"><h5>{{t('files')}}</h5></li>
|
||||||
|
<ul class="list-group results">
|
||||||
|
@for(option of groupedData.files; track option; let index = $index) {
|
||||||
|
<li (click)="handleResultClick(option)" tabindex="0"
|
||||||
|
class="list-group-item" role="option">
|
||||||
|
<ng-container [ngTemplateOutlet]="fileTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!hasData && searchTerm.length > 0 && !isLoading) {
|
||||||
|
|
||||||
|
<ul class="list-group results">
|
||||||
|
<li class="list-group-item">
|
||||||
|
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (hasData && !includeChapterAndFiles) {
|
||||||
|
<li class="list-group-item" style="min-height: 34px">
|
||||||
|
<ng-container [ngTemplateOutlet]="extraTemplate"></ng-container>
|
||||||
|
<a href="javascript:void(0)" (click)="toggleIncludeFiles()" class="float-end">
|
||||||
|
{{t('include-extras')}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
@ -18,8 +18,14 @@ import { debounceTime } from 'rxjs/operators';
|
|||||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||||
import { SearchResultGroup } from 'src/app/_models/search/search-result-group';
|
import { SearchResultGroup } from 'src/app/_models/search/search-result-group';
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import { NgClass, NgIf, NgFor, NgTemplateOutlet } from '@angular/common';
|
import { NgClass, NgTemplateOutlet } from '@angular/common';
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
import {LoadingComponent} from "../../../shared/loading/loading.component";
|
||||||
|
|
||||||
|
export interface SearchEvent {
|
||||||
|
value: string;
|
||||||
|
includeFiles: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-grouped-typeahead',
|
selector: 'app-grouped-typeahead',
|
||||||
@ -27,9 +33,12 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
|||||||
styleUrls: ['./grouped-typeahead.component.scss'],
|
styleUrls: ['./grouped-typeahead.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [ReactiveFormsModule, NgClass, NgIf, NgFor, NgTemplateOutlet, TranslocoDirective]
|
imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective, LoadingComponent]
|
||||||
})
|
})
|
||||||
export class GroupedTypeaheadComponent implements OnInit {
|
export class GroupedTypeaheadComponent implements OnInit {
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unique id to tie with a label element
|
* Unique id to tie with a label element
|
||||||
*/
|
*/
|
||||||
@ -47,6 +56,10 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||||||
* Placeholder for the input
|
* Placeholder for the input
|
||||||
*/
|
*/
|
||||||
@Input() placeholder: string = '';
|
@Input() placeholder: string = '';
|
||||||
|
/**
|
||||||
|
* When the search is active
|
||||||
|
*/
|
||||||
|
@Input() isLoading: boolean = false;
|
||||||
/**
|
/**
|
||||||
* Number of milliseconds after typing before triggering inputChanged for data fetching
|
* Number of milliseconds after typing before triggering inputChanged for data fetching
|
||||||
*/
|
*/
|
||||||
@ -54,7 +67,7 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||||||
/**
|
/**
|
||||||
* Emits when the input changes from user interaction
|
* Emits when the input changes from user interaction
|
||||||
*/
|
*/
|
||||||
@Output() inputChanged: EventEmitter<string> = new EventEmitter();
|
@Output() inputChanged: EventEmitter<SearchEvent> = new EventEmitter();
|
||||||
/**
|
/**
|
||||||
* Emits when something is clicked/selected
|
* Emits when something is clicked/selected
|
||||||
*/
|
*/
|
||||||
@ -76,17 +89,18 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||||||
@ContentChild('personTemplate') personTemplate: TemplateRef<any> | undefined;
|
@ContentChild('personTemplate') personTemplate: TemplateRef<any> | undefined;
|
||||||
@ContentChild('genreTemplate') genreTemplate!: TemplateRef<any>;
|
@ContentChild('genreTemplate') genreTemplate!: TemplateRef<any>;
|
||||||
@ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>;
|
@ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>;
|
||||||
|
@ContentChild('extraTemplate') extraTemplate!: TemplateRef<any>;
|
||||||
@ContentChild('libraryTemplate') libraryTemplate!: TemplateRef<any>;
|
@ContentChild('libraryTemplate') libraryTemplate!: TemplateRef<any>;
|
||||||
@ContentChild('readingListTemplate') readingListTemplate!: TemplateRef<any>;
|
@ContentChild('readingListTemplate') readingListTemplate!: TemplateRef<any>;
|
||||||
@ContentChild('fileTemplate') fileTemplate!: TemplateRef<any>;
|
@ContentChild('fileTemplate') fileTemplate!: TemplateRef<any>;
|
||||||
@ContentChild('chapterTemplate') chapterTemplate!: TemplateRef<any>;
|
@ContentChild('chapterTemplate') chapterTemplate!: TemplateRef<any>;
|
||||||
@ContentChild('bookmarkTemplate') bookmarkTemplate!: TemplateRef<any>;
|
@ContentChild('bookmarkTemplate') bookmarkTemplate!: TemplateRef<any>;
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
|
|
||||||
|
|
||||||
hasFocus: boolean = false;
|
hasFocus: boolean = false;
|
||||||
isLoading: boolean = false;
|
|
||||||
typeaheadForm: FormGroup = new FormGroup({});
|
typeaheadForm: FormGroup = new FormGroup({});
|
||||||
|
includeChapterAndFiles: boolean = false;
|
||||||
|
|
||||||
prevSearchTerm: string = '';
|
prevSearchTerm: string = '';
|
||||||
|
|
||||||
@ -101,8 +115,6 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
constructor(private readonly cdRef: ChangeDetectorRef) { }
|
|
||||||
|
|
||||||
@HostListener('window:click', ['$event'])
|
@HostListener('window:click', ['$event'])
|
||||||
handleDocumentClick(event: any) {
|
handleDocumentClick(event: any) {
|
||||||
this.close();
|
this.close();
|
||||||
@ -127,7 +139,10 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||||||
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
|
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
this.typeaheadForm.valueChanges.pipe(debounceTime(this.debounceTime), takeUntilDestroyed(this.destroyRef)).subscribe(change => {
|
this.typeaheadForm.valueChanges.pipe(
|
||||||
|
debounceTime(this.debounceTime),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe(change => {
|
||||||
const value = this.typeaheadForm.get('typeahead')?.value;
|
const value = this.typeaheadForm.get('typeahead')?.value;
|
||||||
|
|
||||||
if (value != undefined && value != '' && !this.hasFocus) {
|
if (value != undefined && value != '' && !this.hasFocus) {
|
||||||
@ -138,7 +153,7 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||||||
if (value != undefined && value.length >= this.minQueryLength) {
|
if (value != undefined && value.length >= this.minQueryLength) {
|
||||||
|
|
||||||
if (this.prevSearchTerm === value) return;
|
if (this.prevSearchTerm === value) return;
|
||||||
this.inputChanged.emit(value);
|
this.inputChanged.emit({value, includeFiles: this.includeChapterAndFiles});
|
||||||
this.prevSearchTerm = value;
|
this.prevSearchTerm = value;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
@ -164,10 +179,20 @@ export class GroupedTypeaheadComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleResultlick(item: any) {
|
handleResultClick(item: any) {
|
||||||
this.selected.emit(item);
|
this.selected.emit(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleIncludeFiles() {
|
||||||
|
this.includeChapterAndFiles = true;
|
||||||
|
this.inputChanged.emit({value: this.searchTerm, includeFiles: this.includeChapterAndFiles});
|
||||||
|
|
||||||
|
this.hasFocus = true;
|
||||||
|
this.inputElem.nativeElement.focus();
|
||||||
|
this.openDropdown();
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
resetField() {
|
resetField() {
|
||||||
this.prevSearchTerm = '';
|
this.prevSearchTerm = '';
|
||||||
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
|
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
#search
|
#search
|
||||||
id="nav-search"
|
id="nav-search"
|
||||||
[minQueryLength]="2"
|
[minQueryLength]="2"
|
||||||
|
[isLoading]="isLoading"
|
||||||
initialValue=""
|
initialValue=""
|
||||||
[placeholder]="t('search-alt')"
|
[placeholder]="t('search-alt')"
|
||||||
[groupedData]="searchResults"
|
[groupedData]="searchResults"
|
||||||
@ -147,7 +148,6 @@
|
|||||||
<ng-template #noResultsTemplate let-notFound>
|
<ng-template #noResultsTemplate let-notFound>
|
||||||
{{t('no-data')}}
|
{{t('no-data')}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
</app-grouped-typeahead>
|
</app-grouped-typeahead>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,7 +33,7 @@ import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '
|
|||||||
import {EventsWidgetComponent} from '../events-widget/events-widget.component';
|
import {EventsWidgetComponent} from '../events-widget/events-widget.component';
|
||||||
import {SeriesFormatComponent} from '../../../shared/series-format/series-format.component';
|
import {SeriesFormatComponent} from '../../../shared/series-format/series-format.component';
|
||||||
import {ImageComponent} from '../../../shared/image/image.component';
|
import {ImageComponent} from '../../../shared/image/image.component';
|
||||||
import {GroupedTypeaheadComponent} from '../grouped-typeahead/grouped-typeahead.component';
|
import {GroupedTypeaheadComponent, SearchEvent} from '../grouped-typeahead/grouped-typeahead.component';
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
|
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
|
||||||
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
|
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
|
||||||
@ -66,7 +66,6 @@ export class NavHeaderComponent implements OnInit {
|
|||||||
searchResults: SearchResultGroup = new SearchResultGroup();
|
searchResults: SearchResultGroup = new SearchResultGroup();
|
||||||
searchTerm = '';
|
searchTerm = '';
|
||||||
|
|
||||||
|
|
||||||
backToTopNeeded = false;
|
backToTopNeeded = false;
|
||||||
searchFocused: boolean = false;
|
searchFocused: boolean = false;
|
||||||
scrollElem: HTMLElement;
|
scrollElem: HTMLElement;
|
||||||
@ -121,12 +120,14 @@ export class NavHeaderComponent implements OnInit {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
onChangeSearch(val: string) {
|
|
||||||
|
|
||||||
|
onChangeSearch(evt: SearchEvent) {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.searchTerm = val.trim();
|
this.searchTerm = evt.value.trim();
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
this.searchService.search(val.trim()).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(results => {
|
this.searchService.search(this.searchTerm, evt.includeFiles).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(results => {
|
||||||
this.searchResults = results;
|
this.searchResults = results;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
@ -34,7 +34,6 @@ import {SpreadType} from "ngx-extended-pdf-viewer/lib/options/spread-type";
|
|||||||
import {PdfLayoutModePipe} from "../../_pipe/pdf-layout-mode.pipe";
|
import {PdfLayoutModePipe} from "../../_pipe/pdf-layout-mode.pipe";
|
||||||
import {PdfScrollModePipe} from "../../_pipe/pdf-scroll-mode.pipe";
|
import {PdfScrollModePipe} from "../../_pipe/pdf-scroll-mode.pipe";
|
||||||
import {PdfSpreadModePipe} from "../../_pipe/pdf-spread-mode.pipe";
|
import {PdfSpreadModePipe} from "../../_pipe/pdf-spread-mode.pipe";
|
||||||
import {HandtoolChanged} from "ngx-extended-pdf-viewer/lib/events/handtool-changed";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-pdf-reader',
|
selector: 'app-pdf-reader',
|
||||||
|
@ -94,7 +94,7 @@ export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
// If on desktop, we can just have all the data expanded by default:
|
// If on desktop, we can just have all the data expanded by default:
|
||||||
this.isCollapsed = this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop;
|
this.isCollapsed = true; // this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop;
|
||||||
// Check if there is a lot of extended data, if so, re-collapse
|
// Check if there is a lot of extended data, if so, re-collapse
|
||||||
const sum = (this.seriesMetadata.colorists.length + this.seriesMetadata.editors.length
|
const sum = (this.seriesMetadata.colorists.length + this.seriesMetadata.editors.length
|
||||||
+ this.seriesMetadata.coverArtists.length + this.seriesMetadata.inkers.length
|
+ this.seriesMetadata.coverArtists.length + this.seriesMetadata.inkers.length
|
||||||
|
@ -133,7 +133,7 @@ export class UtilityService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
deepEqual(object1: any, object2: any) {
|
deepEqual(object1: any | undefined | null, object2: any | undefined | null) {
|
||||||
if ((object1 === null || object1 === undefined) && (object2 !== null || object2 !== undefined)) return false;
|
if ((object1 === null || object1 === undefined) && (object2 !== null || object2 !== undefined)) return false;
|
||||||
if ((object2 === null || object2 === undefined) && (object1 !== null || object1 !== undefined)) return false;
|
if ((object2 === null || object2 === undefined) && (object1 !== null || object1 !== undefined)) return false;
|
||||||
if (object1 === null && object2 === null) return true;
|
if (object1 === null && object2 === null) return true;
|
||||||
|
@ -65,7 +65,6 @@ export class SideNavComponent implements OnInit {
|
|||||||
homeActions = [
|
homeActions = [
|
||||||
{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.openCustomize.bind(this)},
|
{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.openCustomize.bind(this)},
|
||||||
{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)},
|
{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)},
|
||||||
{action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)}, // This requires the Collection Rework (https://github.com/Kareadita/Kavita/issues/2810)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
filterQuery: string = '';
|
filterQuery: string = '';
|
||||||
@ -144,6 +143,13 @@ export class SideNavComponent implements OnInit {
|
|||||||
this.navService.toggleSideNav();
|
this.navService.toggleSideNav();
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.accountService.hasValidLicense$.subscribe(res =>{
|
||||||
|
if (!res) return;
|
||||||
|
|
||||||
|
this.homeActions.push({action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)});
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
@ -1537,8 +1537,8 @@
|
|||||||
"reading-lists": "Reading Lists",
|
"reading-lists": "Reading Lists",
|
||||||
"collections": "Collections",
|
"collections": "Collections",
|
||||||
"close": "{{common.close}}",
|
"close": "{{common.close}}",
|
||||||
"loading": "{{common.loading}}"
|
"loading": "{{common.loading}}",
|
||||||
|
"include-extras": "Include Chapters & Files"
|
||||||
},
|
},
|
||||||
|
|
||||||
"nav-header": {
|
"nav-header": {
|
||||||
@ -1605,7 +1605,7 @@
|
|||||||
"description": "Import your MAL Interest Stacks and create Collections within Kavita",
|
"description": "Import your MAL Interest Stacks and create Collections within Kavita",
|
||||||
"series-count": "{{common.series-count}}",
|
"series-count": "{{common.series-count}}",
|
||||||
"restack-count": "{{num}} Restacks",
|
"restack-count": "{{num}} Restacks",
|
||||||
"nothing-found": ""
|
"nothing-found": "Nothing found"
|
||||||
},
|
},
|
||||||
|
|
||||||
"edit-chapter-progress": {
|
"edit-chapter-progress": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user