mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-31 14:33:50 -04:00
More Bugfixes (EPUB Mainly) (#2004)
* Fixed an issue with downloading where spaces turned into plus signs. * If the refresh token is invalid, but the auth token still has life in it, don't invalidate. * Fixed docker users unable to save settings * Show a default error icon until favicon loads * Fixed a bug in mappings (keys/files) to pages that caused some links not to map appropriately. Updated epub-reader to v3.3.2. * Expanded Table of Content generation by also checking for any files that are named Navigation.xhtml to have Kavita generate a simple ToC from (instead of just TOC.xhtml) * Added another hack to massage key to page lookups when rewriting anchors. * Cleaned up debugging notes
This commit is contained in:
parent
5f607b3dab
commit
64666540cf
@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.5" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.5" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
||||||
<PackageReference Include="Moq" Version="4.18.4" />
|
<PackageReference Include="Moq" Version="4.18.4" />
|
||||||
<PackageReference Include="NSubstitute" Version="4.4.0" />
|
<PackageReference Include="NSubstitute" Version="4.4.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="19.2.26" />
|
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="19.2.29" />
|
||||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.26" />
|
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.29" />
|
||||||
<PackageReference Include="xunit" Version="2.4.2" />
|
<PackageReference Include="xunit" Version="2.4.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
@ -39,4 +39,5 @@ public class BookParserTests
|
|||||||
// var actual = API.Parser.Parser.CssImportUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3");
|
// var actual = API.Parser.Parser.CssImportUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3");
|
||||||
// Assert.Equal(expected, actual);
|
// Assert.Equal(expected, actual);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -60,8 +60,8 @@
|
|||||||
<PackageReference Include="ExCSS" Version="4.1.0" />
|
<PackageReference Include="ExCSS" Version="4.1.0" />
|
||||||
<PackageReference Include="Flurl" Version="3.0.7" />
|
<PackageReference Include="Flurl" Version="3.0.7" />
|
||||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||||
<PackageReference Include="Hangfire" Version="1.8.0" />
|
<PackageReference Include="Hangfire" Version="1.8.1" />
|
||||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.0" />
|
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.1" />
|
||||||
<PackageReference Include="Hangfire.InMemory" Version="0.4.0" />
|
<PackageReference Include="Hangfire.InMemory" Version="0.4.0" />
|
||||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||||
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
|
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
|
||||||
@ -102,9 +102,9 @@
|
|||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.30.1" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.30.1" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="19.2.26" />
|
<PackageReference Include="System.IO.Abstractions" Version="19.2.29" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
|
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
|
||||||
<PackageReference Include="VersOne.Epub" Version="3.3.0" />
|
<PackageReference Include="VersOne.Epub" Version="3.3.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
@ -98,9 +98,10 @@ public class BookController : BaseApiController
|
|||||||
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions);
|
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions);
|
||||||
|
|
||||||
var key = BookService.CoalesceKeyForAnyFile(book, file);
|
var key = BookService.CoalesceKeyForAnyFile(book, file);
|
||||||
if (!book.Content.AllFiles.Local.ContainsKey(key)) return BadRequest("File was not found in book");
|
|
||||||
|
|
||||||
var bookFile = book.Content.AllFiles.Local[key];
|
if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest("File was not found in book");
|
||||||
|
|
||||||
|
var bookFile = book.Content.AllFiles.GetLocalFileRefByKey(key);
|
||||||
var content = await bookFile.ReadContentAsBytesAsync();
|
var content = await bookFile.ReadContentAsBytesAsync();
|
||||||
|
|
||||||
var contentType = BookService.GetContentType(bookFile.ContentType);
|
var contentType = BookService.GetContentType(bookFile.ContentType);
|
||||||
|
@ -117,7 +117,7 @@ public class DownloadController : BaseApiController
|
|||||||
private ActionResult GetFirstFileDownload(IEnumerable<MangaFile> files)
|
private ActionResult GetFirstFileDownload(IEnumerable<MangaFile> files)
|
||||||
{
|
{
|
||||||
var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files);
|
var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files);
|
||||||
return PhysicalFile(zipFile, contentType, System.Web.HttpUtility.UrlEncode(fileDownloadName), true);
|
return PhysicalFile(zipFile, contentType, Uri.EscapeDataString(fileDownloadName), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -163,7 +163,7 @@ public class DownloadController : BaseApiController
|
|||||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||||
MessageFactory.DownloadProgressEvent(User.GetUsername(),
|
MessageFactory.DownloadProgressEvent(User.GetUsername(),
|
||||||
Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
|
Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
|
||||||
return PhysicalFile(filePath, DefaultContentType, System.Web.HttpUtility.UrlEncode(downloadName), true);
|
return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(downloadName), true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
@ -184,7 +184,7 @@ public class SettingsController : BaseApiController
|
|||||||
|
|
||||||
if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value)
|
if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value)
|
||||||
{
|
{
|
||||||
if (OsInfo.IsDocker) break;
|
if (OsInfo.IsDocker) continue;
|
||||||
setting.Value = updateSettingsDto.Port + string.Empty;
|
setting.Value = updateSettingsDto.Port + string.Empty;
|
||||||
// Port is managed in appSetting.json
|
// Port is managed in appSetting.json
|
||||||
Configuration.Port = updateSettingsDto.Port;
|
Configuration.Port = updateSettingsDto.Port;
|
||||||
@ -193,7 +193,7 @@ public class SettingsController : BaseApiController
|
|||||||
|
|
||||||
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
|
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
|
||||||
{
|
{
|
||||||
if (OsInfo.IsDocker) break;
|
if (OsInfo.IsDocker) continue;
|
||||||
// Validate IP addresses
|
// Validate IP addresses
|
||||||
foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(','))
|
foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(','))
|
||||||
{
|
{
|
||||||
@ -217,14 +217,7 @@ public class SettingsController : BaseApiController
|
|||||||
? $"{path}/"
|
? $"{path}/"
|
||||||
: path;
|
: path;
|
||||||
setting.Value = path;
|
setting.Value = path;
|
||||||
try
|
Configuration.BaseUrl = updateSettingsDto.BaseUrl;
|
||||||
{
|
|
||||||
Configuration.BaseUrl = updateSettingsDto.BaseUrl;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Could not set base url. Give this exception to majora2007");
|
|
||||||
}
|
|
||||||
_unitOfWork.SettingsRepository.Update(setting);
|
_unitOfWork.SettingsRepository.Update(setting);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,7 +127,7 @@ public class BookService : IBookService
|
|||||||
var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty))
|
var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty))
|
||||||
.Split("#");
|
.Split("#");
|
||||||
// Some keys get uri encoded when parsed, so replace any of those characters with original
|
// Some keys get uri encoded when parsed, so replace any of those characters with original
|
||||||
var mappingKey = HttpUtility.UrlDecode(hrefParts[0]);
|
var mappingKey = Uri.UnescapeDataString(hrefParts[0]);
|
||||||
|
|
||||||
if (!mappings.ContainsKey(mappingKey))
|
if (!mappings.ContainsKey(mappingKey))
|
||||||
{
|
{
|
||||||
@ -136,6 +136,15 @@ public class BookService : IBookService
|
|||||||
var part = hrefParts.Length > 1
|
var part = hrefParts.Length > 1
|
||||||
? hrefParts[1]
|
? hrefParts[1]
|
||||||
: anchor.GetAttributeValue("href", string.Empty);
|
: anchor.GetAttributeValue("href", string.Empty);
|
||||||
|
|
||||||
|
// hrefParts[0] might not have path from mappings
|
||||||
|
var pageKey = mappings.Keys.FirstOrDefault(mKey => mKey.EndsWith(hrefParts[0]));
|
||||||
|
if (!string.IsNullOrEmpty(pageKey))
|
||||||
|
{
|
||||||
|
mappings.TryGetValue(pageKey, out currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
anchor.Attributes.Add("kavita-page", $"{currentPage}");
|
anchor.Attributes.Add("kavita-page", $"{currentPage}");
|
||||||
anchor.Attributes.Add("kavita-part", part);
|
anchor.Attributes.Add("kavita-part", part);
|
||||||
anchor.Attributes.Remove("href");
|
anchor.Attributes.Remove("href");
|
||||||
@ -186,9 +195,9 @@ public class BookService : IBookService
|
|||||||
{
|
{
|
||||||
key = prepend + key;
|
key = prepend + key;
|
||||||
}
|
}
|
||||||
if (!book.Content.AllFiles.Local.ContainsKey(key)) continue;
|
if (!book.Content.AllFiles.TryGetLocalFileRefByKey(key, out var bookFile)) continue;
|
||||||
|
|
||||||
var bookFile = book.Content.AllFiles.Local[key];
|
//var bookFile = book.Content.AllFiles.Local[key];
|
||||||
var content = await bookFile.ReadContentAsBytesAsync();
|
var content = await bookFile.ReadContentAsBytesAsync();
|
||||||
importBuilder.Append(Encoding.UTF8.GetString(content));
|
importBuilder.Append(Encoding.UTF8.GetString(content));
|
||||||
}
|
}
|
||||||
@ -227,7 +236,6 @@ public class BookService : IBookService
|
|||||||
|
|
||||||
private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend)
|
private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend)
|
||||||
{
|
{
|
||||||
//foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex().Matches(stylesheetHtml))
|
|
||||||
foreach (Match match in Parser.CssImportUrlRegex.Matches(stylesheetHtml))
|
foreach (Match match in Parser.CssImportUrlRegex.Matches(stylesheetHtml))
|
||||||
{
|
{
|
||||||
if (!match.Success) continue;
|
if (!match.Success) continue;
|
||||||
@ -238,7 +246,6 @@ public class BookService : IBookService
|
|||||||
|
|
||||||
private static void EscapeFontFamilyReferences(ref string stylesheetHtml, string apiBase, string prepend)
|
private static void EscapeFontFamilyReferences(ref string stylesheetHtml, string apiBase, string prepend)
|
||||||
{
|
{
|
||||||
//foreach (Match match in Tasks.Scanner.Parser.Parser.FontSrcUrlRegex().Matches(stylesheetHtml))
|
|
||||||
foreach (Match match in Parser.FontSrcUrlRegex.Matches(stylesheetHtml))
|
foreach (Match match in Parser.FontSrcUrlRegex.Matches(stylesheetHtml))
|
||||||
{
|
{
|
||||||
if (!match.Success) continue;
|
if (!match.Success) continue;
|
||||||
@ -249,7 +256,6 @@ public class BookService : IBookService
|
|||||||
|
|
||||||
private static void EscapeCssImageReferences(ref string stylesheetHtml, string apiBase, EpubBookRef book)
|
private static void EscapeCssImageReferences(ref string stylesheetHtml, string apiBase, EpubBookRef book)
|
||||||
{
|
{
|
||||||
//var matches = Tasks.Scanner.Parser.Parser.CssImageUrlRegex().Matches(stylesheetHtml);
|
|
||||||
var matches = Parser.CssImageUrlRegex.Matches(stylesheetHtml);
|
var matches = Parser.CssImageUrlRegex.Matches(stylesheetHtml);
|
||||||
foreach (Match match in matches)
|
foreach (Match match in matches)
|
||||||
{
|
{
|
||||||
@ -257,7 +263,7 @@ public class BookService : IBookService
|
|||||||
|
|
||||||
var importFile = match.Groups["Filename"].Value;
|
var importFile = match.Groups["Filename"].Value;
|
||||||
var key = CleanContentKeys(importFile);
|
var key = CleanContentKeys(importFile);
|
||||||
if (!book.Content.AllFiles.Local.ContainsKey(key)) continue;
|
if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) continue;
|
||||||
|
|
||||||
stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + key);
|
stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + key);
|
||||||
}
|
}
|
||||||
@ -290,7 +296,7 @@ public class BookService : IBookService
|
|||||||
var imageFile = GetKeyForImage(book, image.Attributes[key].Value);
|
var imageFile = GetKeyForImage(book, image.Attributes[key].Value);
|
||||||
image.Attributes.Remove(key);
|
image.Attributes.Remove(key);
|
||||||
// UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx
|
// UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx
|
||||||
image.Attributes.Add(key, $"{apiBase}" + HttpUtility.UrlEncode(imageFile));
|
image.Attributes.Add(key, $"{apiBase}" + Uri.EscapeDataString(imageFile));
|
||||||
|
|
||||||
// Add a custom class that the reader uses to ensure images stay within reader
|
// Add a custom class that the reader uses to ensure images stay within reader
|
||||||
parent.AddClass("kavita-scale-width-container");
|
parent.AddClass("kavita-scale-width-container");
|
||||||
@ -307,9 +313,9 @@ public class BookService : IBookService
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
private static string GetKeyForImage(EpubBookRef book, string imageFile)
|
private static string GetKeyForImage(EpubBookRef book, string imageFile)
|
||||||
{
|
{
|
||||||
if (book.Content.Images.Local.ContainsKey(imageFile)) return imageFile;
|
if (book.Content.Images.ContainsLocalFileRefWithKey(imageFile)) return imageFile;
|
||||||
|
|
||||||
var correctedKey = book.Content.Images.Local.Keys.SingleOrDefault(s => s.EndsWith(imageFile));
|
var correctedKey = book.Content.Images.Local.Select(s => s.Key).SingleOrDefault(s => s.EndsWith(imageFile));
|
||||||
if (correctedKey != null)
|
if (correctedKey != null)
|
||||||
{
|
{
|
||||||
imageFile = correctedKey;
|
imageFile = correctedKey;
|
||||||
@ -318,13 +324,14 @@ public class BookService : IBookService
|
|||||||
{
|
{
|
||||||
// There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg
|
// There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg
|
||||||
correctedKey =
|
correctedKey =
|
||||||
book.Content.Images.Local.Keys.SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty)));
|
book.Content.Images.Local.Select(s => s.Key).SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty)));
|
||||||
if (correctedKey != null)
|
if (correctedKey != null)
|
||||||
{
|
{
|
||||||
imageFile = correctedKey;
|
imageFile = correctedKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return imageFile;
|
return imageFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,6 +349,7 @@ public class BookService : IBookService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void RewriteAnchors(int page, HtmlDocument doc, Dictionary<string, int> mappings)
|
private static void RewriteAnchors(int page, HtmlDocument doc, Dictionary<string, int> mappings)
|
||||||
|
|
||||||
{
|
{
|
||||||
var anchors = doc.DocumentNode.SelectNodes("//a");
|
var anchors = doc.DocumentNode.SelectNodes("//a");
|
||||||
if (anchors == null) return;
|
if (anchors == null) return;
|
||||||
@ -372,9 +380,9 @@ public class BookService : IBookService
|
|||||||
var key = CleanContentKeys(styleLinks.Attributes["href"].Value);
|
var key = CleanContentKeys(styleLinks.Attributes["href"].Value);
|
||||||
// Some epubs are malformed the key in content.opf might be: content/resources/filelist_0_0.xml but the actual html links to resources/filelist_0_0.xml
|
// Some epubs are malformed the key in content.opf might be: content/resources/filelist_0_0.xml but the actual html links to resources/filelist_0_0.xml
|
||||||
// In this case, we will do a search for the key that ends with
|
// In this case, we will do a search for the key that ends with
|
||||||
if (!book.Content.Css.Local.ContainsKey(key))
|
if (!book.Content.Css.ContainsLocalFileRefWithKey(key))
|
||||||
{
|
{
|
||||||
var correctedKey = book.Content.Css.Local.Keys.SingleOrDefault(s => s.EndsWith(key));
|
var correctedKey = book.Content.Css.Local.Select(s => s.Key).SingleOrDefault(s => s.EndsWith(key));
|
||||||
if (correctedKey == null)
|
if (correctedKey == null)
|
||||||
{
|
{
|
||||||
_logger.LogError("Epub is Malformed, key: {Key} is not matching OPF file", key);
|
_logger.LogError("Epub is Malformed, key: {Key} is not matching OPF file", key);
|
||||||
@ -386,7 +394,7 @@ public class BookService : IBookService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cssFile = book.Content.Css.Local[key];
|
var cssFile = book.Content.Css.GetLocalFileRefByKey(key);
|
||||||
|
|
||||||
var styleContent = await ScopeStyles(await cssFile.ReadContentAsync(), apiBase,
|
var styleContent = await ScopeStyles(await cssFile.ReadContentAsync(), apiBase,
|
||||||
cssFile.FilePath, book);
|
cssFile.FilePath, book);
|
||||||
@ -507,6 +515,7 @@ public class BookService : IBookService
|
|||||||
item.Property == "display-seq" && item.Refines == metadataItem.Refines);
|
item.Property == "display-seq" && item.Refines == metadataItem.Refines);
|
||||||
if (count == null || count.Content == "0")
|
if (count == null || count.Content == "0")
|
||||||
{
|
{
|
||||||
|
// TODO: Rewrite this to use a StringBuilder
|
||||||
// Treat this as a Collection
|
// Treat this as a Collection
|
||||||
info.SeriesGroup += (string.IsNullOrEmpty(info.StoryArc) ? string.Empty : ",") + readingListElem.Title.Replace(",", "_");
|
info.SeriesGroup += (string.IsNullOrEmpty(info.StoryArc) ? string.Empty : ",") + readingListElem.Title.Replace(",", "_");
|
||||||
}
|
}
|
||||||
@ -687,7 +696,9 @@ public class BookService : IBookService
|
|||||||
foreach (var contentFileRef in await book.GetReadingOrderAsync())
|
foreach (var contentFileRef in await book.GetReadingOrderAsync())
|
||||||
{
|
{
|
||||||
if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) continue;
|
if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) continue;
|
||||||
|
// Some keys are different than FilePath, so we add both to ease loookup
|
||||||
dict.Add(contentFileRef.FilePath, pageCount); // FileName -> FilePath
|
dict.Add(contentFileRef.FilePath, pageCount); // FileName -> FilePath
|
||||||
|
dict.TryAdd(contentFileRef.Key, pageCount); // FileName -> FilePath
|
||||||
pageCount += 1;
|
pageCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -861,7 +872,7 @@ public class BookService : IBookService
|
|||||||
if (mappings.ContainsKey(CleanContentKeys(key))) return key;
|
if (mappings.ContainsKey(CleanContentKeys(key))) return key;
|
||||||
|
|
||||||
// Fallback to searching for key (bad epub metadata)
|
// Fallback to searching for key (bad epub metadata)
|
||||||
var correctedKey = book.Content.Html.Local.Keys.FirstOrDefault(s => s.EndsWith(key));
|
var correctedKey = book.Content.Html.Local.Select(s => s.Key).FirstOrDefault(s => s.EndsWith(key));
|
||||||
if (!string.IsNullOrEmpty(correctedKey))
|
if (!string.IsNullOrEmpty(correctedKey))
|
||||||
{
|
{
|
||||||
key = correctedKey;
|
key = correctedKey;
|
||||||
@ -885,17 +896,18 @@ public class BookService : IBookService
|
|||||||
|
|
||||||
public static string CoalesceKeyForAnyFile(EpubBookRef book, string key)
|
public static string CoalesceKeyForAnyFile(EpubBookRef book, string key)
|
||||||
{
|
{
|
||||||
if (book.Content.AllFiles.Local.ContainsKey(key)) return key;
|
if (book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return key;
|
||||||
|
|
||||||
var cleanedKey = CleanContentKeys(key);
|
var cleanedKey = CleanContentKeys(key);
|
||||||
if (book.Content.AllFiles.Local.ContainsKey(cleanedKey)) return cleanedKey;
|
if (book.Content.AllFiles.ContainsLocalFileRefWithKey(cleanedKey)) return cleanedKey;
|
||||||
|
|
||||||
|
// TODO: Figure this out
|
||||||
// Fallback to searching for key (bad epub metadata)
|
// Fallback to searching for key (bad epub metadata)
|
||||||
var correctedKey = book.Content.AllFiles.Local.Keys.SingleOrDefault(s => s.EndsWith(key));
|
// var correctedKey = book.Content.AllFiles.Keys.SingleOrDefault(s => s.EndsWith(key));
|
||||||
if (!string.IsNullOrEmpty(correctedKey))
|
// if (!string.IsNullOrEmpty(correctedKey))
|
||||||
{
|
// {
|
||||||
key = correctedKey;
|
// key = correctedKey;
|
||||||
}
|
// }
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
@ -928,7 +940,7 @@ public class BookService : IBookService
|
|||||||
|
|
||||||
foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
|
foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null))
|
||||||
{
|
{
|
||||||
var key = CoalesceKey(book, mappings, nestedChapter.Link?.ContentFileName);
|
var key = CoalesceKey(book, mappings, nestedChapter.Link?.ContentFilePath);
|
||||||
if (mappings.TryGetValue(key, out var mapping))
|
if (mappings.TryGetValue(key, out var mapping))
|
||||||
{
|
{
|
||||||
nestedChapters.Add(new BookChapterItem
|
nestedChapters.Add(new BookChapterItem
|
||||||
@ -947,12 +959,15 @@ public class BookService : IBookService
|
|||||||
|
|
||||||
if (chaptersList.Count != 0) return chaptersList;
|
if (chaptersList.Count != 0) return chaptersList;
|
||||||
// Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
|
// Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
|
||||||
var tocPage = book.Content.Html.Local.Keys.FirstOrDefault(k => k.ToUpper().Contains("TOC"));
|
var tocPage = book.Content.Html.Local.Select(s => s.Key).FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
|
||||||
if (tocPage == null) return chaptersList;
|
k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
if (string.IsNullOrEmpty(tocPage)) return chaptersList;
|
||||||
|
|
||||||
// Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content
|
// Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content
|
||||||
|
if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
|
||||||
|
var content = await file.ReadContentAsync();
|
||||||
|
|
||||||
var doc = new HtmlDocument();
|
var doc = new HtmlDocument();
|
||||||
var content = await book.Content.Html.Local[tocPage].ReadContentAsync();
|
|
||||||
doc.LoadHtml(content);
|
doc.LoadHtml(content);
|
||||||
var anchors = doc.DocumentNode.SelectNodes("//a");
|
var anchors = doc.DocumentNode.SelectNodes("//a");
|
||||||
if (anchors == null) return chaptersList;
|
if (anchors == null) return chaptersList;
|
||||||
@ -1096,7 +1111,7 @@ public class BookService : IBookService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var groupKey = CoalesceKey(book, mappings, navigationItem.Link.ContentFileName);
|
var groupKey = CoalesceKey(book, mappings, navigationItem.Link.ContentFilePath);
|
||||||
if (mappings.ContainsKey(groupKey))
|
if (mappings.ContainsKey(groupKey))
|
||||||
{
|
{
|
||||||
chaptersList.Add(new BookChapterItem
|
chaptersList.Add(new BookChapterItem
|
||||||
@ -1133,8 +1148,8 @@ public class BookService : IBookService
|
|||||||
{
|
{
|
||||||
// Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one.
|
// Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one.
|
||||||
var coverImageContent = epubBook.Content.Cover
|
var coverImageContent = epubBook.Content.Cover
|
||||||
?? epubBook.Content.Images.Local.Values.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath)) // FileName -> FilePath
|
?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath)) // FileName -> FilePath
|
||||||
?? epubBook.Content.Images.Local.Values.FirstOrDefault();
|
?? epubBook.Content.Images.Local.FirstOrDefault();
|
||||||
|
|
||||||
if (coverImageContent == null) return string.Empty;
|
if (coverImageContent == null) return string.Empty;
|
||||||
using var stream = coverImageContent.GetContentStream();
|
using var stream = coverImageContent.GetContentStream();
|
||||||
|
@ -172,7 +172,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||||||
{
|
{
|
||||||
using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions);
|
using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions);
|
||||||
|
|
||||||
var totalPages = book.Content.Html.Local.Values;
|
var totalPages = book.Content.Html.Local;
|
||||||
foreach (var bookPage in totalPages)
|
foreach (var bookPage in totalPages)
|
||||||
{
|
{
|
||||||
var progress = Math.Max(0F,
|
var progress = Math.Max(0F,
|
||||||
|
@ -1058,4 +1058,19 @@ public static class Parser
|
|||||||
{
|
{
|
||||||
return string.IsNullOrEmpty(name) ? string.Empty : name.Replace('_', ' ');
|
return string.IsNullOrEmpty(name) ? string.Empty : name.Replace('_', ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string? ExtractFilename(string fileUrl)
|
||||||
|
{
|
||||||
|
var matches = Parser.CssImageUrlRegex.Matches(fileUrl);
|
||||||
|
foreach (Match match in matches)
|
||||||
|
{
|
||||||
|
if (!match.Success) continue;
|
||||||
|
|
||||||
|
// NOTE: This is failing for //localhost:5000/api/book/29919/book-resources?file=OPS/images/tick1.jpg
|
||||||
|
var importFile = match.Groups["Filename"].Value;
|
||||||
|
if (!importFile.Contains("?")) return importFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -97,9 +97,8 @@ public class TokenService : ITokenService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken);
|
var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken);
|
||||||
if (!validated)
|
if (!validated && tokenContent.ValidTo > DateTime.UtcNow.Add(TimeSpan.FromHours(1)))
|
||||||
{
|
{
|
||||||
|
|
||||||
_logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token");
|
_logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -771,7 +771,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
const links = this.readingSectionElemRef.nativeElement.querySelectorAll('a');
|
const links = this.readingSectionElemRef.nativeElement.querySelectorAll('a');
|
||||||
links.forEach((link: any) => {
|
links.forEach((link: any) => {
|
||||||
link.addEventListener('click', (e: any) => {
|
link.addEventListener('click', (e: any) => {
|
||||||
console.log('Link clicked: ', e);
|
|
||||||
if (!e.target.attributes.hasOwnProperty('kavita-page')) { return; }
|
if (!e.target.attributes.hasOwnProperty('kavita-page')) { return; }
|
||||||
const page = parseInt(e.target.attributes['kavita-page'].value, 10);
|
const page = parseInt(e.target.attributes['kavita-page'].value, 10);
|
||||||
if (this.adhocPageHistory.peek()?.page !== this.pageNum) {
|
if (this.adhocPageHistory.peek()?.page !== this.pageNum) {
|
||||||
|
@ -9,10 +9,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<a class="col me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" *ngFor="let link of links" [title]="link">
|
<a class="col me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" *ngFor="let link of links" [title]="link">
|
||||||
<img width="24px" height="24px" #img class="lazyload img-placeholder"
|
<img width="24px" height="24px" #img class="lazyload img-placeholder"
|
||||||
src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
[src]="imageService.errorWebLinkImage"
|
||||||
[attr.data-src]="imageService.getWebLinkImage(link)"
|
[attr.data-src]="imageService.getWebLinkImage(link)"
|
||||||
(error)="imageService.updateErroredWebLinkImage($event)"
|
(error)="imageService.updateErroredWebLinkImage($event)"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -28,7 +28,7 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.genres">
|
<app-badge-expander [items]="seriesMetadata.genres">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Genres, item.id)" [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
|
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Genres, item.id)" [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -40,7 +40,7 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.tags">
|
<app-badge-expander [items]="seriesMetadata.tags">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Tags, item.id)" [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
|
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Tags, item.id)" [selectionMode]="TagBadgeCursor.Clickable">{{item.title}}</app-tag-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -54,7 +54,7 @@
|
|||||||
<app-tag-badge a11y-click="13,32" class="col-auto" routerLink="/collections/{{item.id}}" [selectionMode]="TagBadgeCursor.Clickable">
|
<app-tag-badge a11y-click="13,32" class="col-auto" routerLink="/collections/{{item.id}}" [selectionMode]="TagBadgeCursor.Clickable">
|
||||||
{{item.title}}
|
{{item.title}}
|
||||||
</app-tag-badge>
|
</app-tag-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -72,7 +72,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{{item.title}}
|
{{item.title}}
|
||||||
</app-tag-badge>
|
</app-tag-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -84,7 +84,7 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.writers">
|
<app-badge-expander [items]="seriesMetadata.writers">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Writers, item.id)" [person]="item"></app-person-badge>
|
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Writers, item.id)" [person]="item"></app-person-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -98,11 +98,11 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.coverArtists">
|
<app-badge-expander [items]="seriesMetadata.coverArtists">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.CoverArtists, item.id)" [person]="item"></app-person-badge>
|
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.CoverArtists, item.id)" [person]="item"></app-person-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-0 mt-1" *ngIf="seriesMetadata.characters && seriesMetadata.characters.length > 0">
|
<div class="row g-0 mt-1" *ngIf="seriesMetadata.characters && seriesMetadata.characters.length > 0">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<h5>Characters</h5>
|
<h5>Characters</h5>
|
||||||
@ -111,7 +111,7 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.characters">
|
<app-badge-expander [items]="seriesMetadata.characters">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Character, item.id)" [person]="item"></app-person-badge>
|
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Character, item.id)" [person]="item"></app-person-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -124,7 +124,7 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.colorists">
|
<app-badge-expander [items]="seriesMetadata.colorists">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Colorist, item.id)" [person]="item"></app-person-badge>
|
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Colorist, item.id)" [person]="item"></app-person-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -137,7 +137,7 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.editors">
|
<app-badge-expander [items]="seriesMetadata.editors">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Editor, item.id)" [person]="item"></app-person-badge>
|
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Editor, item.id)" [person]="item"></app-person-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -150,7 +150,7 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.inkers">
|
<app-badge-expander [items]="seriesMetadata.inkers">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Inker, item.id)" [person]="item"></app-person-badge>
|
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Inker, item.id)" [person]="item"></app-person-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -163,7 +163,7 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.letterers">
|
<app-badge-expander [items]="seriesMetadata.letterers">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Letterer, item.id)" [person]="item"></app-person-badge>
|
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Letterer, item.id)" [person]="item"></app-person-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -175,11 +175,11 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.translators">
|
<app-badge-expander [items]="seriesMetadata.translators">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Translator, item.id)" [person]="item"></app-person-badge>
|
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Translator, item.id)" [person]="item"></app-person-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-0 mt-1" *ngIf="seriesMetadata.pencillers && seriesMetadata.pencillers.length > 0">
|
<div class="row g-0 mt-1" *ngIf="seriesMetadata.pencillers && seriesMetadata.pencillers.length > 0">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<h5>Pencillers</h5>
|
<h5>Pencillers</h5>
|
||||||
@ -188,7 +188,7 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.pencillers">
|
<app-badge-expander [items]="seriesMetadata.pencillers">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Penciller, item.id)" [person]="item"></app-person-badge>
|
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Penciller, item.id)" [person]="item"></app-person-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -201,20 +201,20 @@
|
|||||||
<app-badge-expander [items]="seriesMetadata.publishers">
|
<app-badge-expander [items]="seriesMetadata.publishers">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Publisher, item.id)" [person]="item"></app-person-badge>
|
<app-person-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Publisher, item.id)" [person]="item"></app-person-badge>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<hr class="col mt-3" *ngIf="hasExtendedProperites" >
|
<hr class="col mt-3" *ngIf="hasExtendedProperties" >
|
||||||
<a [class.hidden]="hasExtendedProperites" *ngIf="hasExtendedProperites"
|
<a [class.hidden]="hasExtendedProperties" *ngIf="hasExtendedProperties"
|
||||||
class="col col-md-auto align-self-end read-more-link" (click)="toggleView()">
|
class="col col-md-auto align-self-end read-more-link" (click)="toggleView()">
|
||||||
<i aria-hidden="true" class="fa fa-caret-{{isCollapsed ? 'down' : 'up'}} me-1" aria-controls="extended-series-metadata"></i>
|
<i aria-hidden="true" class="fa fa-caret-{{isCollapsed ? 'down' : 'up'}} me-1" aria-controls="extended-series-metadata"></i>
|
||||||
See {{isCollapsed ? 'More' : 'Less'}}
|
See {{isCollapsed ? 'More' : 'Less'}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- This first row will have random information about the series-->
|
<!-- This first row will have random information about the series-->
|
||||||
<app-series-info-cards [series]="series" [seriesMetadata]="seriesMetadata" (goTo)="handleGoTo($event)" [hasReadingProgress]="hasReadingProgress"></app-series-info-cards>
|
<app-series-info-cards [series]="series" [seriesMetadata]="seriesMetadata" (goTo)="handleGoTo($event)" [hasReadingProgress]="hasReadingProgress"></app-series-info-cards>
|
||||||
|
@ -29,7 +29,7 @@ export class SeriesMetadataDetailComponent implements OnChanges {
|
|||||||
@Input() series!: Series;
|
@Input() series!: Series;
|
||||||
|
|
||||||
isCollapsed: boolean = true;
|
isCollapsed: boolean = true;
|
||||||
hasExtendedProperites: boolean = false;
|
hasExtendedProperties: boolean = false;
|
||||||
|
|
||||||
imageService = inject(ImageService);
|
imageService = inject(ImageService);
|
||||||
|
|
||||||
@ -55,20 +55,20 @@ export class SeriesMetadataDetailComponent implements OnChanges {
|
|||||||
return this.seriesMetadata?.webLinks.split(',') || [];
|
return this.seriesMetadata?.webLinks.split(',') || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(public utilityService: UtilityService, public metadataService: MetadataService,
|
constructor(public utilityService: UtilityService, public metadataService: MetadataService,
|
||||||
private router: Router, public readerService: ReaderService,
|
private router: Router, public readerService: ReaderService,
|
||||||
private readonly cdRef: ChangeDetectorRef) {
|
private readonly cdRef: ChangeDetectorRef) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
this.hasExtendedProperites = this.seriesMetadata.colorists.length > 0 ||
|
this.hasExtendedProperties = this.seriesMetadata.colorists.length > 0 ||
|
||||||
this.seriesMetadata.editors.length > 0 ||
|
this.seriesMetadata.editors.length > 0 ||
|
||||||
this.seriesMetadata.coverArtists.length > 0 ||
|
this.seriesMetadata.coverArtists.length > 0 ||
|
||||||
this.seriesMetadata.inkers.length > 0 ||
|
this.seriesMetadata.inkers.length > 0 ||
|
||||||
this.seriesMetadata.letterers.length > 0 ||
|
this.seriesMetadata.letterers.length > 0 ||
|
||||||
this.seriesMetadata.pencillers.length > 0 ||
|
this.seriesMetadata.pencillers.length > 0 ||
|
||||||
this.seriesMetadata.publishers.length > 0 ||
|
this.seriesMetadata.publishers.length > 0 ||
|
||||||
this.seriesMetadata.translators.length > 0 ||
|
this.seriesMetadata.translators.length > 0 ||
|
||||||
this.seriesMetadata.tags.length > 0;
|
this.seriesMetadata.tags.length > 0;
|
||||||
|
|
||||||
|
@ -1,200 +0,0 @@
|
|||||||
import { ElementRef, Injectable } from '@angular/core';
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
export class DomHelperService {
|
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
// from: https://stackoverflow.com/questions/40597658/equivalent-of-angular-equals-in-angular2#44649659
|
|
||||||
deepEquals(x: any, y: any) {
|
|
||||||
if (x === y) {
|
|
||||||
return true; // if both x and y are null or undefined and exactly the same
|
|
||||||
} else if (!(x instanceof Object) || !(y instanceof Object)) {
|
|
||||||
return false; // if they are not strictly equal, they both need to be Objects
|
|
||||||
} else if (x.constructor !== y.constructor) {
|
|
||||||
// they must have the exact same prototype chain, the closest we can do is
|
|
||||||
// test their constructor.
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
for (const p in x) {
|
|
||||||
if (!x.hasOwnProperty(p)) {
|
|
||||||
continue; // other properties were tested using x.constructor === y.constructor
|
|
||||||
}
|
|
||||||
if (!y.hasOwnProperty(p)) {
|
|
||||||
return false; // allows to compare x[ p ] and y[ p ] when set to undefined
|
|
||||||
}
|
|
||||||
if (x[p] === y[p]) {
|
|
||||||
continue; // if they have the same strict value or identity then they are equal
|
|
||||||
}
|
|
||||||
if (typeof (x[p]) !== 'object') {
|
|
||||||
return false; // Numbers, Strings, Functions, Booleans must be strictly equal
|
|
||||||
}
|
|
||||||
if (!this.deepEquals(x[p], y[p])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const p in y) {
|
|
||||||
if (y.hasOwnProperty(p) && !x.hasOwnProperty(p)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isHidden(node: ElementRef){
|
|
||||||
const el = node.nativeElement?node.nativeElement:node;
|
|
||||||
const elemStyle = window.getComputedStyle(el);
|
|
||||||
|
|
||||||
return el.style.display === 'none' || elemStyle.visibility === 'hidden' || el.hasAttribute('hidden') || elemStyle.display === 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
isTabable(node: ElementRef): boolean {
|
|
||||||
const el = node.nativeElement?node.nativeElement:node;
|
|
||||||
const tagName = el.tagName;
|
|
||||||
|
|
||||||
if(this.isHidden(node)){
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// el.attribute:NamdedNodeMap
|
|
||||||
if (el.attributes.hasOwnProperty('tabindex')) {
|
|
||||||
return (parseInt(el.attributes.getNamedItem('tabindex'),10) >= 0);
|
|
||||||
}
|
|
||||||
if (tagName === 'A' || tagName === 'AREA' || tagName === 'BUTTON' || tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
|
|
||||||
if (tagName === 'A' || tagName === 'AREA') {
|
|
||||||
return (el.attributes.getNamedItem('href') !== '');
|
|
||||||
}
|
|
||||||
return !el.attributes.hasOwnProperty('disabled'); // check for cases when: disabled="true" and disabled="false"
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isValidChild(child: any): boolean { // child:ElementRef.nativeElement
|
|
||||||
return child.nodeType == 1 && child.nodeName != 'SCRIPT' && child.nodeName != 'STYLE';
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasValidParent(obj: any) { // obj:ElementRef.nativeElement
|
|
||||||
return (this.isValidChild(obj) && obj.parentElement.nodeName !== 'BODY');
|
|
||||||
}
|
|
||||||
|
|
||||||
private traverse(obj: any, fromTop: boolean): ElementRef | undefined | boolean {
|
|
||||||
// obj:ElementRef||ElementRef.nativeElement
|
|
||||||
var obj = obj? (obj.nativeElement?obj.nativeElement:obj) : document.getElementsByTagName('body')[0];
|
|
||||||
if (this.isValidChild(obj) && this.isTabable(obj)) {
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
// If object is hidden, skip it's children
|
|
||||||
if (this.isValidChild(obj) && this.isHidden(obj)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// If object is hidden, skip it's children
|
|
||||||
if (obj.classList && obj.classList.contains('ng-hide')) { // some nodes don't have classList?!
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (obj.hasChildNodes()) {
|
|
||||||
var child;
|
|
||||||
if (fromTop) {
|
|
||||||
child = obj.firstChild;
|
|
||||||
} else {
|
|
||||||
child = obj.lastChild;
|
|
||||||
}
|
|
||||||
while(child) {
|
|
||||||
var res = this.traverse(child, fromTop);
|
|
||||||
if(res){
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
if (fromTop) {
|
|
||||||
child = child.nextSibling;
|
|
||||||
} else {
|
|
||||||
child = child.previousSibling;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
previousElement(el: any, isFocusable: boolean): any { // ElementRef | undefined | boolean
|
|
||||||
|
|
||||||
var elem = el.nativeElement ? el.nativeElement : el;
|
|
||||||
if (el.hasOwnProperty('length')) {
|
|
||||||
elem = el[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
var parent = elem.parentElement;
|
|
||||||
var previousElem = undefined;
|
|
||||||
|
|
||||||
if(isFocusable) {
|
|
||||||
if (this.hasValidParent(elem)) {
|
|
||||||
var siblings = parent.children;
|
|
||||||
if (siblings.length > 0) {
|
|
||||||
// Good practice to splice out the elem from siblings if there, saving some time.
|
|
||||||
// We allow for a quick check for jumping to parent first before removing.
|
|
||||||
if (siblings[0] === elem) {
|
|
||||||
// If we are looking at immidiate parent and elem is first child, we need to go higher
|
|
||||||
var e = this.previousElement(elem.parentNode, isFocusable);
|
|
||||||
if (this.isTabable(e)) {
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// I need to filter myself and any nodes next to me from the siblings
|
|
||||||
var indexOfElem = Array.prototype.indexOf.call(siblings, elem);
|
|
||||||
const that = this;
|
|
||||||
siblings = Array.prototype.filter.call(siblings, function(item, itemIndex) {
|
|
||||||
if (!that.deepEquals(elem, item) && itemIndex < indexOfElem) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// We need to search backwards
|
|
||||||
for (var i = 0; i <= siblings.length-1; i++) {//for (var i = siblings.length-1; i >= 0; i--) {
|
|
||||||
var ret = this.traverse(siblings[i], false);
|
|
||||||
if (ret !== undefined) {
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var e = this.previousElement(elem.parentNode, isFocusable);
|
|
||||||
if (this.isTabable(e)) {
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var siblings = parent.children;
|
|
||||||
if (siblings.length > 1) {
|
|
||||||
// Since indexOf is on Array.prototype and parent.children is a NodeList, we have to use call()
|
|
||||||
var index = Array.prototype.indexOf.call(siblings, elem);
|
|
||||||
previousElem = siblings[index-1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return previousElem;
|
|
||||||
};
|
|
||||||
lastTabableElement(el: any) {
|
|
||||||
/* This will return the first tabable element from the parent el */
|
|
||||||
var elem = el.nativeElement?el.nativeElement:el;
|
|
||||||
if (el.hasOwnProperty('length')) {
|
|
||||||
elem = el[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.traverse(elem, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
firstTabableElement(el: any) {
|
|
||||||
/* This will return the first tabable element from the parent el */
|
|
||||||
var elem = el.nativeElement ? el.nativeElement : el;
|
|
||||||
if (el.hasOwnProperty('length')) {
|
|
||||||
elem = el[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.traverse(elem, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
isInDOM(obj: Node) {
|
|
||||||
return document.documentElement.contains(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -29,7 +29,7 @@ export interface DownloadEvent {
|
|||||||
/**
|
/**
|
||||||
* Progress of the download itself
|
* Progress of the download itself
|
||||||
*/
|
*/
|
||||||
progress: number;
|
progress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,7 +37,7 @@ export interface DownloadEvent {
|
|||||||
*/
|
*/
|
||||||
export type DownloadEntityType = 'volume' | 'chapter' | 'series' | 'bookmark' | 'logs';
|
export type DownloadEntityType = 'volume' | 'chapter' | 'series' | 'bookmark' | 'logs';
|
||||||
/**
|
/**
|
||||||
* Valid entities for downloading. Undefined exclusively for logs.
|
* Valid entities for downloading. Undefined exclusively for logs.
|
||||||
*/
|
*/
|
||||||
export type DownloadEntity = Series | Volume | Chapter | PageBookmark[] | undefined;
|
export type DownloadEntity = Series | Volume | Chapter | PageBookmark[] | undefined;
|
||||||
|
|
||||||
@ -56,14 +56,14 @@ export class DownloadService {
|
|||||||
public activeDownloads$ = this.downloadsSource.asObservable();
|
public activeDownloads$ = this.downloadsSource.asObservable();
|
||||||
|
|
||||||
|
|
||||||
constructor(private httpClient: HttpClient, private confirmService: ConfirmService,
|
constructor(private httpClient: HttpClient, private confirmService: ConfirmService,
|
||||||
@Inject(SAVER) private save: Saver, private accountService: AccountService) { }
|
@Inject(SAVER) private save: Saver, private accountService: AccountService) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the entity subtitle (for the event widget) for a given entity
|
* Returns the entity subtitle (for the event widget) for a given entity
|
||||||
* @param downloadEntityType
|
* @param downloadEntityType
|
||||||
* @param downloadEntity
|
* @param downloadEntity
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
downloadSubtitle(downloadEntityType: DownloadEntityType, downloadEntity: DownloadEntity | undefined) {
|
downloadSubtitle(downloadEntityType: DownloadEntityType, downloadEntity: DownloadEntity | undefined) {
|
||||||
switch (downloadEntityType) {
|
switch (downloadEntityType) {
|
||||||
@ -82,13 +82,13 @@ export class DownloadService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads the entity to the user's system. This handles everything around downloads. This will prompt the user based on size checks and UserPreferences.PromptForDownload.
|
* Downloads the entity to the user's system. This handles everything around downloads. This will prompt the user based on size checks and UserPreferences.PromptForDownload.
|
||||||
* This will perform the download at a global level, if you need a handle to the download in question, use downloadService.activeDownloads$ and perform a filter on it.
|
* This will perform the download at a global level, if you need a handle to the download in question, use downloadService.activeDownloads$ and perform a filter on it.
|
||||||
* @param entityType
|
* @param entityType
|
||||||
* @param entity
|
* @param entity
|
||||||
* @param callback Optional callback. Returns the download or undefined (if the download is complete).
|
* @param callback Optional callback. Returns the download or undefined (if the download is complete).
|
||||||
*/
|
*/
|
||||||
download(entityType: DownloadEntityType, entity: DownloadEntity, callback?: (d: Download | undefined) => void) {
|
download(entityType: DownloadEntityType, entity: DownloadEntity, callback?: (d: Download | undefined) => void) {
|
||||||
let sizeCheckCall: Observable<number>;
|
let sizeCheckCall: Observable<number>;
|
||||||
let downloadCall: Observable<Download>;
|
let downloadCall: Observable<Download>;
|
||||||
switch (entityType) {
|
switch (entityType) {
|
||||||
case 'series':
|
case 'series':
|
||||||
@ -155,10 +155,10 @@ export class DownloadService {
|
|||||||
private downloadLogs() {
|
private downloadLogs() {
|
||||||
const downloadType = 'logs';
|
const downloadType = 'logs';
|
||||||
const subtitle = this.downloadSubtitle(downloadType, undefined);
|
const subtitle = this.downloadSubtitle(downloadType, undefined);
|
||||||
return this.httpClient.get(this.baseUrl + 'server/logs',
|
return this.httpClient.get(this.baseUrl + 'server/logs',
|
||||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||||
).pipe(
|
).pipe(
|
||||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||||
download((blob, filename) => {
|
download((blob, filename) => {
|
||||||
this.save(blob, decodeURIComponent(filename));
|
this.save(blob, decodeURIComponent(filename));
|
||||||
}),
|
}),
|
||||||
@ -170,10 +170,10 @@ export class DownloadService {
|
|||||||
private downloadSeries(series: Series) {
|
private downloadSeries(series: Series) {
|
||||||
const downloadType = 'series';
|
const downloadType = 'series';
|
||||||
const subtitle = this.downloadSubtitle(downloadType, series);
|
const subtitle = this.downloadSubtitle(downloadType, series);
|
||||||
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
|
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
|
||||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||||
).pipe(
|
).pipe(
|
||||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||||
download((blob, filename) => {
|
download((blob, filename) => {
|
||||||
this.save(blob, decodeURIComponent(filename));
|
this.save(blob, decodeURIComponent(filename));
|
||||||
}),
|
}),
|
||||||
@ -209,11 +209,12 @@ export class DownloadService {
|
|||||||
private downloadChapter(chapter: Chapter) {
|
private downloadChapter(chapter: Chapter) {
|
||||||
const downloadType = 'chapter';
|
const downloadType = 'chapter';
|
||||||
const subtitle = this.downloadSubtitle(downloadType, chapter);
|
const subtitle = this.downloadSubtitle(downloadType, chapter);
|
||||||
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
|
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
|
||||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||||
).pipe(
|
).pipe(
|
||||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||||
download((blob, filename) => {
|
download((blob, filename) => {
|
||||||
|
console.log('saving: ', filename)
|
||||||
this.save(blob, decodeURIComponent(filename));
|
this.save(blob, decodeURIComponent(filename));
|
||||||
}),
|
}),
|
||||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
|
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
|
||||||
@ -224,10 +225,10 @@ export class DownloadService {
|
|||||||
private downloadVolume(volume: Volume): Observable<Download> {
|
private downloadVolume(volume: Volume): Observable<Download> {
|
||||||
const downloadType = 'volume';
|
const downloadType = 'volume';
|
||||||
const subtitle = this.downloadSubtitle(downloadType, volume);
|
const subtitle = this.downloadSubtitle(downloadType, volume);
|
||||||
return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
|
return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
|
||||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||||
).pipe(
|
).pipe(
|
||||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||||
download((blob, filename) => {
|
download((blob, filename) => {
|
||||||
this.save(blob, decodeURIComponent(filename));
|
this.save(blob, decodeURIComponent(filename));
|
||||||
}),
|
}),
|
||||||
@ -244,10 +245,10 @@ export class DownloadService {
|
|||||||
const downloadType = 'bookmark';
|
const downloadType = 'bookmark';
|
||||||
const subtitle = this.downloadSubtitle(downloadType, bookmarks);
|
const subtitle = this.downloadSubtitle(downloadType, bookmarks);
|
||||||
|
|
||||||
return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks},
|
return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks},
|
||||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||||
).pipe(
|
).pipe(
|
||||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||||
download((blob, filename) => {
|
download((blob, filename) => {
|
||||||
this.save(blob, decodeURIComponent(filename));
|
this.save(blob, decodeURIComponent(filename));
|
||||||
}),
|
}),
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.7.2.11"
|
"version": "0.7.2.12"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user