mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Web Links (#1983)
* Updated dependencies * Updated the default key to be 256 bits to meet security requirements. * Added basic implementation of web link resolving favicon. Needs lots more work and testing on all OSes. * Implemented ability to see links and click on them for an individual chapter. * Hooked up the ability to set Series web links. * Render out the web link * Refactored out the favicon so there is a backup in case it fails. Refactored the baseline image placeholders to be dark mode since that is the default. * Added Robbie's nice error weblink fallbacks.
This commit is contained in:
parent
23fde65a7b
commit
bd8a1821a7
1
.gitignore
vendored
1
.gitignore
vendored
@ -512,6 +512,7 @@ UI/Web/dist/
|
|||||||
/API/config/themes/
|
/API/config/themes/
|
||||||
/API/config/stats/
|
/API/config/stats/
|
||||||
/API/config/bookmarks/
|
/API/config/bookmarks/
|
||||||
|
/API/config/favicons/
|
||||||
/API/config/kavita.db
|
/API/config/kavita.db
|
||||||
/API/config/kavita.db-shm
|
/API/config/kavita.db-shm
|
||||||
/API/config/kavita.db-wal
|
/API/config/kavita.db-wal
|
||||||
|
@ -10,8 +10,8 @@
|
|||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.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.11" />
|
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="19.2.22" />
|
||||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.11" />
|
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.22" />
|
||||||
<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>
|
||||||
|
@ -59,12 +59,12 @@
|
|||||||
<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.7.34" />
|
<PackageReference Include="Hangfire" Version="1.8.0" />
|
||||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.34" />
|
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.0" />
|
||||||
<PackageReference Include="Hangfire.InMemory" Version="0.3.7" />
|
<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" />
|
||||||
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.3" />
|
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.4" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.46" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.46" />
|
||||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.5" />
|
||||||
@ -83,24 +83,24 @@
|
|||||||
<PackageReference Include="NetVips.Native" Version="8.14.2" />
|
<PackageReference Include="NetVips.Native" Version="8.14.2" />
|
||||||
<PackageReference Include="NReco.Logging.File" Version="1.1.6" />
|
<PackageReference Include="NReco.Logging.File" Version="1.1.6" />
|
||||||
<PackageReference Include="Serilog" Version="2.12.0" />
|
<PackageReference Include="Serilog" Version="2.12.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="6.1.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />
|
||||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
|
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
|
||||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="5.0.1" />
|
<PackageReference Include="Serilog.Extensions.Hosting" Version="7.0.0" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="7.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
|
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
||||||
<PackageReference Include="SharpCompress" Version="0.33.0" />
|
<PackageReference Include="SharpCompress" Version="0.33.0" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.55.0.65544">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.0.0.68202">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<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.29.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.30.1" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="19.2.11" />
|
<PackageReference Include="System.IO.Abstractions" Version="19.2.22" />
|
||||||
<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-alpha1" />
|
<PackageReference Include="VersOne.Epub" Version="3.3.0-alpha1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.IO;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
@ -20,12 +21,14 @@ public class ImageController : BaseApiController
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
|
private readonly IImageService _imageService;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService)
|
public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService, IImageService imageService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_directoryService = directoryService;
|
_directoryService = directoryService;
|
||||||
|
_imageService = imageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -157,6 +160,42 @@ public class ImageController : BaseApiController
|
|||||||
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
|
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the image associated with a web-link
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="chapterId"></param>
|
||||||
|
/// <param name="pageNum"></param>
|
||||||
|
/// <param name="apiKey"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("web-link")]
|
||||||
|
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = new []{"url", "apiKey"})]
|
||||||
|
public async Task<ActionResult> GetBookmarkImage(string url, string apiKey)
|
||||||
|
{
|
||||||
|
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||||
|
if (userId == 0) return BadRequest();
|
||||||
|
|
||||||
|
// Check if the domain exists
|
||||||
|
var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, ImageService.GetWebLinkFormat(url));
|
||||||
|
if (!_directoryService.FileSystem.File.Exists(domainFilePath))
|
||||||
|
{
|
||||||
|
// We need to request the favicon and save it
|
||||||
|
try
|
||||||
|
{
|
||||||
|
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory,
|
||||||
|
await _imageService.DownloadFaviconAsync(url));
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return BadRequest("There was an issue fetching favicon for domain");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = new FileInfo(domainFilePath);
|
||||||
|
var format = Path.GetExtension(file.FullName);
|
||||||
|
|
||||||
|
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a temp coverupload image
|
/// Returns a temp coverupload image
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -93,4 +93,8 @@ public class ChapterDto : IHasReadTimeEstimate, IEntityDate
|
|||||||
public int MaxHoursToRead { get; set; }
|
public int MaxHoursToRead { get; set; }
|
||||||
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
|
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
|
||||||
public int AvgHoursToRead { get; set; }
|
public int AvgHoursToRead { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Comma-separated link of urls to external services that have some relation to the Chapter
|
||||||
|
/// </summary>
|
||||||
|
public string WebLinks { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -58,6 +58,10 @@ public class SeriesMetadataDto
|
|||||||
/// Publication status of the Series
|
/// Publication status of the Series
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public PublicationStatus PublicationStatus { get; set; }
|
public PublicationStatus PublicationStatus { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// A comma-separated list of Urls
|
||||||
|
/// </summary>
|
||||||
|
public string WebLinks { get; set; }
|
||||||
|
|
||||||
public bool LanguageLocked { get; set; }
|
public bool LanguageLocked { get; set; }
|
||||||
public bool SummaryLocked { get; set; }
|
public bool SummaryLocked { get; set; }
|
||||||
|
@ -114,6 +114,13 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||||||
builder.Entity<Library>()
|
builder.Entity<Library>()
|
||||||
.Property(b => b.ManageReadingLists)
|
.Property(b => b.ManageReadingLists)
|
||||||
.HasDefaultValue(true);
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
builder.Entity<Chapter>()
|
||||||
|
.Property(b => b.WebLinks)
|
||||||
|
.HasDefaultValue(string.Empty);
|
||||||
|
builder.Entity<SeriesMetadata>()
|
||||||
|
.Property(b => b.WebLinks)
|
||||||
|
.HasDefaultValue(string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -38,6 +38,7 @@ public class ComicInfo
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// This is the link to where the data was scraped from
|
/// This is the link to where the data was scraped from
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>This can be comma-separated</remarks>
|
||||||
public string Web { get; set; } = string.Empty;
|
public string Web { get; set; } = string.Empty;
|
||||||
[System.ComponentModel.DefaultValueAttribute(0)]
|
[System.ComponentModel.DefaultValueAttribute(0)]
|
||||||
public int Day { get; set; } = 0;
|
public int Day { get; set; } = 0;
|
||||||
|
1917
API/Data/Migrations/20230511165427_WebLinksForChapter.Designer.cs
generated
Normal file
1917
API/Data/Migrations/20230511165427_WebLinksForChapter.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
API/Data/Migrations/20230511165427_WebLinksForChapter.cs
Normal file
29
API/Data/Migrations/20230511165427_WebLinksForChapter.cs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class WebLinksForChapter : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "WebLinks",
|
||||||
|
table: "Chapter",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "WebLinks",
|
||||||
|
table: "Chapter");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1922
API/Data/Migrations/20230511183339_WebLinksForSeries.Designer.cs
generated
Normal file
1922
API/Data/Migrations/20230511183339_WebLinksForSeries.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
API/Data/Migrations/20230511183339_WebLinksForSeries.cs
Normal file
29
API/Data/Migrations/20230511183339_WebLinksForSeries.cs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class WebLinksForSeries : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "WebLinks",
|
||||||
|
table: "SeriesMetadata",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "WebLinks",
|
||||||
|
table: "SeriesMetadata");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -467,6 +467,11 @@ namespace API.Data.Migrations
|
|||||||
b.Property<int>("VolumeId")
|
b.Property<int>("VolumeId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("WebLinks")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
b.Property<long>("WordCount")
|
b.Property<long>("WordCount")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@ -831,6 +836,11 @@ namespace API.Data.Migrations
|
|||||||
b.Property<bool>("TranslatorLocked")
|
b.Property<bool>("TranslatorLocked")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("WebLinks")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
b.Property<bool>("WriterLocked")
|
b.Property<bool>("WriterLocked")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
@ -100,7 +100,10 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
|
|||||||
public int MaxHoursToRead { get; set; }
|
public int MaxHoursToRead { get; set; }
|
||||||
/// <inheritdoc cref="IHasReadTimeEstimate"/>
|
/// <inheritdoc cref="IHasReadTimeEstimate"/>
|
||||||
public int AvgHoursToRead { get; set; }
|
public int AvgHoursToRead { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Comma-separated link of urls to external services that have some relation to the Chapter
|
||||||
|
/// </summary>
|
||||||
|
public string WebLinks { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All people attached at a Chapter level. Usually Comics will have different people per issue.
|
/// All people attached at a Chapter level. Usually Comics will have different people per issue.
|
||||||
@ -115,7 +118,6 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
|
|||||||
public ICollection<AppUserProgress> UserProgress { get; set; }
|
public ICollection<AppUserProgress> UserProgress { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public Volume Volume { get; set; } = null!;
|
public Volume Volume { get; set; } = null!;
|
||||||
public int VolumeId { get; set; }
|
public int VolumeId { get; set; }
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Interfaces;
|
using API.Entities.Interfaces;
|
||||||
@ -43,6 +44,11 @@ public class SeriesMetadata : IHasConcurrencyToken
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int MaxCount { get; set; } = 0;
|
public int MaxCount { get; set; } = 0;
|
||||||
public PublicationStatus PublicationStatus { get; set; }
|
public PublicationStatus PublicationStatus { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// A Comma-separated list of strings representing links from the series
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This is not populated from Chapters of the Series</remarks>
|
||||||
|
public string WebLinks { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Locks
|
// Locks
|
||||||
public bool LanguageLocked { get; set; }
|
public bool LanguageLocked { get; set; }
|
||||||
|
@ -49,7 +49,7 @@ public class Program
|
|||||||
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development)
|
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development)
|
||||||
{
|
{
|
||||||
Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions...");
|
Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions...");
|
||||||
var rBytes = new byte[128];
|
var rBytes = new byte[256];
|
||||||
RandomNumberGenerator.Create().GetBytes(rBytes);
|
RandomNumberGenerator.Create().GetBytes(rBytes);
|
||||||
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
|
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ public interface IDirectoryService
|
|||||||
string TempDirectory { get; }
|
string TempDirectory { get; }
|
||||||
string ConfigDirectory { get; }
|
string ConfigDirectory { get; }
|
||||||
string SiteThemeDirectory { get; }
|
string SiteThemeDirectory { get; }
|
||||||
|
string FaviconDirectory { get; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
|
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -75,6 +76,7 @@ public class DirectoryService : IDirectoryService
|
|||||||
public string ConfigDirectory { get; }
|
public string ConfigDirectory { get; }
|
||||||
public string BookmarkDirectory { get; }
|
public string BookmarkDirectory { get; }
|
||||||
public string SiteThemeDirectory { get; }
|
public string SiteThemeDirectory { get; }
|
||||||
|
public string FaviconDirectory { get; }
|
||||||
private readonly ILogger<DirectoryService> _logger;
|
private readonly ILogger<DirectoryService> _logger;
|
||||||
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
|
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
|
||||||
|
|
||||||
@ -98,6 +100,7 @@ public class DirectoryService : IDirectoryService
|
|||||||
ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config");
|
ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config");
|
||||||
BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks");
|
BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks");
|
||||||
SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes");
|
SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes");
|
||||||
|
FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons");
|
||||||
|
|
||||||
ExistOrCreate(SiteThemeDirectory);
|
ExistOrCreate(SiteThemeDirectory);
|
||||||
ExistOrCreate(CoverImageDirectory);
|
ExistOrCreate(CoverImageDirectory);
|
||||||
@ -105,6 +108,7 @@ public class DirectoryService : IDirectoryService
|
|||||||
ExistOrCreate(LogDirectory);
|
ExistOrCreate(LogDirectory);
|
||||||
ExistOrCreate(TempDirectory);
|
ExistOrCreate(TempDirectory);
|
||||||
ExistOrCreate(BookmarkDirectory);
|
ExistOrCreate(BookmarkDirectory);
|
||||||
|
ExistOrCreate(FaviconDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -1,10 +1,18 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Flurl;
|
||||||
|
using Flurl.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NetVips;
|
using NetVips;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Formats.Png;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
using Image = NetVips.Image;
|
using Image = NetVips.Image;
|
||||||
|
using Size = SixLabors.ImageSharp.Size;
|
||||||
|
|
||||||
namespace API.Services;
|
namespace API.Services;
|
||||||
|
|
||||||
@ -49,6 +57,7 @@ public interface IImageService
|
|||||||
Task<string> ConvertToWebP(string filePath, string outputPath);
|
Task<string> ConvertToWebP(string filePath, string outputPath);
|
||||||
|
|
||||||
Task<bool> IsImage(string filePath);
|
Task<bool> IsImage(string filePath);
|
||||||
|
Task<string> DownloadFaviconAsync(string url);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ImageService : IImageService
|
public class ImageService : IImageService
|
||||||
@ -177,6 +186,36 @@ public class ImageService : IImageService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> DownloadFaviconAsync(string url)
|
||||||
|
{
|
||||||
|
// Parse the URL to get the domain (including subdomain)
|
||||||
|
var uri = new Uri(url);
|
||||||
|
var domain = uri.Host;
|
||||||
|
var baseUrl = uri.Scheme + "://" + uri.Host;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Download the favicon.ico file using Flurl
|
||||||
|
var faviconStream = await baseUrl
|
||||||
|
.AppendPathSegment("favicon.ico")
|
||||||
|
.AllowHttpStatus("2xx")
|
||||||
|
.GetStreamAsync();
|
||||||
|
|
||||||
|
// Create the destination file path
|
||||||
|
var filename = $"{domain}.png";
|
||||||
|
using var icon = new Icon(faviconStream);
|
||||||
|
using var bitmap = icon.ToBitmap();
|
||||||
|
bitmap.Save(Path.Combine(_directoryService.FaviconDirectory, filename), ImageFormat.Png);
|
||||||
|
|
||||||
|
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error downloading favicon.png for ${Domain}", domain);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string CreateThumbnailFromBase64(string encodedImage, string fileName, bool saveAsWebP = false, int thumbnailWidth = ThumbnailWidth)
|
public string CreateThumbnailFromBase64(string encodedImage, string fileName, bool saveAsWebP = false, int thumbnailWidth = ThumbnailWidth)
|
||||||
@ -257,6 +296,11 @@ public class ImageService : IImageService
|
|||||||
return $"thumbnail{chapterId}";
|
return $"thumbnail{chapterId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string GetWebLinkFormat(string url)
|
||||||
|
{
|
||||||
|
return $"{new Uri(url).Host}.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public static string CreateMergedImage(List<string> coverImages, string dest)
|
public static string CreateMergedImage(List<string> coverImages, string dest)
|
||||||
{
|
{
|
||||||
|
@ -120,6 +120,16 @@ public class SeriesService : ISeriesService
|
|||||||
series.Metadata.LanguageLocked = true;
|
series.Metadata.LanguageLocked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(updateSeriesMetadataDto.SeriesMetadata?.WebLinks))
|
||||||
|
{
|
||||||
|
series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks
|
||||||
|
.Split(",")
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
|
.Select(s => s.Trim())!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
series.Metadata.CollectionTags ??= new List<CollectionTag>();
|
series.Metadata.CollectionTags ??= new List<CollectionTag>();
|
||||||
UpdateCollectionsList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) =>
|
UpdateCollectionsList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) =>
|
||||||
{
|
{
|
||||||
|
@ -120,6 +120,8 @@ public class BackupService : IBackupService
|
|||||||
await SendProgress(0.75F, "Copying themes");
|
await SendProgress(0.75F, "Copying themes");
|
||||||
|
|
||||||
CopyThemesToBackupDirectory(tempDirectory);
|
CopyThemesToBackupDirectory(tempDirectory);
|
||||||
|
await SendProgress(0.85F, "Copying favicons");
|
||||||
|
CopyFaviconsToBackupDirectory(tempDirectory);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -141,6 +143,11 @@ public class BackupService : IBackupService
|
|||||||
_directoryService.CopyFilesToDirectory(files, _directoryService.FileSystem.Path.Join(tempDirectory, "logs"));
|
_directoryService.CopyFilesToDirectory(files, _directoryService.FileSystem.Path.Join(tempDirectory, "logs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void CopyFaviconsToBackupDirectory(string tempDirectory)
|
||||||
|
{
|
||||||
|
_directoryService.CopyDirectoryToDirectory(_directoryService.FaviconDirectory, tempDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task CopyCoverImagesToBackupDirectory(string tempDirectory)
|
private async Task CopyCoverImagesToBackupDirectory(string tempDirectory)
|
||||||
{
|
{
|
||||||
var outputTempDir = Path.Join(tempDirectory, "covers");
|
var outputTempDir = Path.Join(tempDirectory, "covers");
|
||||||
|
@ -708,12 +708,19 @@ public class ProcessSeries : IProcessSeries
|
|||||||
chapter.StoryArcNumber = comicInfo.StoryArcNumber;
|
chapter.StoryArcNumber = comicInfo.StoryArcNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (comicInfo.AlternateCount > 0)
|
if (comicInfo.AlternateCount > 0)
|
||||||
{
|
{
|
||||||
chapter.AlternateCount = comicInfo.AlternateCount;
|
chapter.AlternateCount = comicInfo.AlternateCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(comicInfo.Web))
|
||||||
|
{
|
||||||
|
chapter.WebLinks = string.Join(",", comicInfo.Web
|
||||||
|
.Split(",")
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
|
.Select(s => s.Trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (comicInfo.Count > 0)
|
if (comicInfo.Count > 0)
|
||||||
{
|
{
|
||||||
|
@ -49,13 +49,12 @@ public class TokenService : ITokenService
|
|||||||
|
|
||||||
claims.AddRange(roles.Select(role => new Claim(Role, role)));
|
claims.AddRange(roles.Select(role => new Claim(Role, role)));
|
||||||
|
|
||||||
var creds = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature);
|
var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature);
|
||||||
|
|
||||||
var tokenDescriptor = new SecurityTokenDescriptor()
|
var tokenDescriptor = new SecurityTokenDescriptor()
|
||||||
{
|
{
|
||||||
Subject = new ClaimsIdentity(claims),
|
Subject = new ClaimsIdentity(claims),
|
||||||
Expires = DateTime.UtcNow.AddDays(14),
|
Expires = DateTime.UtcNow.AddDays(14),
|
||||||
SigningCredentials = creds
|
SigningCredentials = credentials
|
||||||
};
|
};
|
||||||
|
|
||||||
var tokenHandler = new JwtSecurityTokenHandler();
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"TokenKey": "super secret unguessable key",
|
"TokenKey": "super secret unguessable key that is longer because we require it",
|
||||||
"Port": 5000,
|
"Port": 5000,
|
||||||
"IpAddresses": "",
|
"IpAddresses": "",
|
||||||
"BaseUrl": "/joe/"
|
"BaseUrl": "/joe/"
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.55.0.65544">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.0.0.68202">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
@ -41,4 +41,5 @@ export interface Chapter {
|
|||||||
* 'Volume number'. Only available for SeriesDetail
|
* 'Volume number'. Only available for SeriesDetail
|
||||||
*/
|
*/
|
||||||
volumeTitle?: string;
|
volumeTitle?: string;
|
||||||
|
webLinks: string;
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,7 @@ export interface SeriesMetadata {
|
|||||||
releaseYear: number;
|
releaseYear: number;
|
||||||
language: string;
|
language: string;
|
||||||
publicationStatus: PublicationStatus;
|
publicationStatus: PublicationStatus;
|
||||||
|
webLinks: string;
|
||||||
|
|
||||||
summaryLocked: boolean;
|
summaryLocked: boolean;
|
||||||
genresLocked: boolean;
|
genresLocked: boolean;
|
||||||
|
@ -14,9 +14,10 @@ export class ImageService implements OnDestroy {
|
|||||||
baseUrl = environment.apiUrl;
|
baseUrl = environment.apiUrl;
|
||||||
apiKey: string = '';
|
apiKey: string = '';
|
||||||
encodedKey: string = '';
|
encodedKey: string = '';
|
||||||
public placeholderImage = 'assets/images/image-placeholder-min.png';
|
public placeholderImage = 'assets/images/image-placeholder.dark-min.png';
|
||||||
public errorImage = 'assets/images/error-placeholder2-min.png';
|
public errorImage = 'assets/images/error-placeholder2.dark-min.png';
|
||||||
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
|
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
|
||||||
|
public errorWebLinkImage = 'assets/images/broken-white-32x32.png';
|
||||||
|
|
||||||
private onDestroy: Subject<void> = new Subject();
|
private onDestroy: Subject<void> = new Subject();
|
||||||
|
|
||||||
@ -25,9 +26,11 @@ export class ImageService implements OnDestroy {
|
|||||||
if (this.themeService.isDarkTheme()) {
|
if (this.themeService.isDarkTheme()) {
|
||||||
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
|
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
|
||||||
this.errorImage = 'assets/images/error-placeholder2.dark-min.png';
|
this.errorImage = 'assets/images/error-placeholder2.dark-min.png';
|
||||||
|
this.errorWebLinkImage = 'assets/images/broken-white-32x32.png';
|
||||||
} else {
|
} else {
|
||||||
this.placeholderImage = 'assets/images/image-placeholder-min.png';
|
this.placeholderImage = 'assets/images/image-placeholder-min.png';
|
||||||
this.errorImage = 'assets/images/error-placeholder2-min.png';
|
this.errorImage = 'assets/images/error-placeholder2-min.png';
|
||||||
|
this.errorWebLinkImage = 'assets/images/broken-black-32x32.png';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -91,6 +94,10 @@ export class ImageService implements OnDestroy {
|
|||||||
return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}`;
|
return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getWebLinkImage(url: string) {
|
||||||
|
return `${this.baseUrl}image/web-link?url=${encodeURIComponent(url)}&apiKey=${this.encodedKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
getCoverUploadImage(filename: string) {
|
getCoverUploadImage(filename: string) {
|
||||||
return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`;
|
return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`;
|
||||||
}
|
}
|
||||||
@ -99,6 +106,10 @@ export class ImageService implements OnDestroy {
|
|||||||
event.target.src = this.placeholderImage;
|
event.target.src = this.placeholderImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateErroredWebLinkImage(event: any) {
|
||||||
|
event.target.src = this.errorWebLinkImage;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to refresh an existing loaded image (lazysizes). If random already attached, will append another number onto it.
|
* Used to refresh an existing loaded image (lazysizes). If random already attached, will append another number onto it.
|
||||||
* @param url Existing request url from ImageService only
|
* @param url Existing request url from ImageService only
|
||||||
|
@ -11,60 +11,61 @@
|
|||||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||||
|
|
||||||
<li [ngbNavItem]="tabs[TabID.General]">
|
<li [ngbNavItem]="tabs[TabID.General]">
|
||||||
<a ngbNavLink>{{tabs[TabID.General]}}</a>
|
<a ngbNavLink>{{tabs[TabID.General]}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="mb-3" style="width: 100%">
|
<div class="mb-3" style="width: 100%">
|
||||||
<label for="name" class="form-label">Name</label>
|
<label for="name" class="form-label">Name</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input id="name" class="form-control" formControlName="name" type="text" [class.is-invalid]="editSeriesForm.get('name')?.invalid && editSeriesForm.get('name')?.touched">
|
<input id="name" class="form-control" formControlName="name" type="text" [class.is-invalid]="editSeriesForm.get('name')?.invalid && editSeriesForm.get('name')?.touched">
|
||||||
<ng-container *ngIf="editSeriesForm.get('name')?.errors as errors">
|
<ng-container *ngIf="editSeriesForm.get('name')?.errors as errors">
|
||||||
<div class="invalid-feedback" *ngIf="errors.required">
|
<div class="invalid-feedback" *ngIf="errors.required">
|
||||||
This field is required
|
This field is required
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="mb-3" style="width: 100%">
|
<div class="mb-3" style="width: 100%">
|
||||||
<label for="sort-name" class="form-label">Sort Name</label>
|
<label for="sort-name" class="form-label">Sort Name</label>
|
||||||
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}"
|
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}"
|
||||||
[class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
|
[class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
|
||||||
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
||||||
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
|
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
|
||||||
<div class="invalid-feedback" *ngIf="errors.required">
|
<div class="invalid-feedback" *ngIf="errors.required">
|
||||||
This field is required
|
This field is required
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="mb-3" style="width: 100%">
|
<div class="mb-3" style="width: 100%">
|
||||||
<label for="localized-name" class="form-label">Localized Name</label>
|
<label for="localized-name" class="form-label">Localized Name</label>
|
||||||
<div class="input-group {{series.localizedNameLocked ? 'lock-active' : ''}}">
|
<div class="input-group {{series.localizedNameLocked ? 'lock-active' : ''}}">
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'localizedNameLocked' }"></ng-container>
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'localizedNameLocked' }"></ng-container>
|
||||||
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
|
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-0" *ngIf="metadata">
|
<div class="row g-0" *ngIf="metadata">
|
||||||
<div class="mb-3" style="width: 100%">
|
<div class="mb-3" style="width: 100%">
|
||||||
<label for="summary" class="form-label">Summary</label>
|
<label for="summary" class="form-label">Summary</label>
|
||||||
<div class="input-group {{metadata.summaryLocked ? 'lock-active' : ''}}">
|
<div class="input-group {{metadata.summaryLocked ? 'lock-active' : ''}}">
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'summaryLocked' }"></ng-container>
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'summaryLocked' }"></ng-container>
|
||||||
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
|
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li [ngbNavItem]="tabs[TabID.Metadata]" *ngIf="metadata">
|
<li [ngbNavItem]="tabs[TabID.Metadata]" *ngIf="metadata">
|
||||||
<a ngbNavLink>{{tabs[TabID.Metadata]}}</a>
|
<a ngbNavLink>{{tabs[TabID.Metadata]}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
@ -343,6 +344,27 @@
|
|||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li [ngbNavItem]="tabs[TabID.WebLinks]" *ngIf="metadata">
|
||||||
|
<a ngbNavLink>{{tabs[TabID.WebLinks]}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<p>Here you can add many different links to external services.</p>
|
||||||
|
<div class="row g-0 mb-3" *ngFor="let link of WebLinks; let i = index;">
|
||||||
|
<div class="col-lg-8 col-md-12 pe-2">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="web-link--{{i}}" class="visually-hidden">Web Link</label>
|
||||||
|
<input type="text" class="form-control" formControlName="link{{i}}" attr.id="web-link--{{i}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-2">
|
||||||
|
<button class="btn btn-secondary" (click)="addWebLink()">
|
||||||
|
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
||||||
|
<span class="visually-hidden">Add Link</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li [ngbNavItem]="tabs[TabID.CoverImage]">
|
<li [ngbNavItem]="tabs[TabID.CoverImage]">
|
||||||
<a ngbNavLink>{{tabs[TabID.CoverImage]}}</a>
|
<a ngbNavLink>{{tabs[TabID.CoverImage]}}</a>
|
||||||
|
@ -26,9 +26,10 @@ enum TabID {
|
|||||||
General = 0,
|
General = 0,
|
||||||
Metadata = 1,
|
Metadata = 1,
|
||||||
People = 2,
|
People = 2,
|
||||||
CoverImage = 3,
|
WebLinks = 3,
|
||||||
Related = 4,
|
CoverImage = 4,
|
||||||
Info = 5,
|
Related = 5,
|
||||||
|
Info = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -49,7 +50,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
isCollapsed = true;
|
isCollapsed = true;
|
||||||
volumeCollapsed: any = {};
|
volumeCollapsed: any = {};
|
||||||
tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Related', 'Info'];
|
tabs = ['General', 'Metadata', 'People', 'Web Links', 'Cover Image', 'Related', 'Info'];
|
||||||
active = this.tabs[0];
|
active = this.tabs[0];
|
||||||
activeTabId = TabID.General;
|
activeTabId = TabID.General;
|
||||||
editSeriesForm!: FormGroup;
|
editSeriesForm!: FormGroup;
|
||||||
@ -100,6 +101,10 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
return TabID;
|
return TabID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get WebLinks() {
|
||||||
|
return this.metadata?.webLinks.split(',') || [''];
|
||||||
|
}
|
||||||
|
|
||||||
getPersonsSettings(role: PersonRole) {
|
getPersonsSettings(role: PersonRole) {
|
||||||
return this.peopleSettings[role];
|
return this.peopleSettings[role];
|
||||||
}
|
}
|
||||||
@ -168,6 +173,11 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
this.editSeriesForm.get('publicationStatus')?.patchValue(this.metadata.publicationStatus);
|
this.editSeriesForm.get('publicationStatus')?.patchValue(this.metadata.publicationStatus);
|
||||||
this.editSeriesForm.get('language')?.patchValue(this.metadata.language);
|
this.editSeriesForm.get('language')?.patchValue(this.metadata.language);
|
||||||
this.editSeriesForm.get('releaseYear')?.patchValue(this.metadata.releaseYear);
|
this.editSeriesForm.get('releaseYear')?.patchValue(this.metadata.releaseYear);
|
||||||
|
|
||||||
|
this.WebLinks.forEach((link, index) => {
|
||||||
|
this.editSeriesForm.addControl('link' + index, new FormControl(link, [Validators.required]));
|
||||||
|
});
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||||
@ -474,6 +484,13 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
save() {
|
save() {
|
||||||
const model = this.editSeriesForm.value;
|
const model = this.editSeriesForm.value;
|
||||||
const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0;
|
const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0;
|
||||||
|
this.metadata.webLinks = Object.keys(this.editSeriesForm.controls)
|
||||||
|
.filter(key => key.startsWith('link'))
|
||||||
|
.map(key => this.editSeriesForm.get(key)?.value)
|
||||||
|
.filter(v => v !== null && v !== '')
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const apis = [
|
const apis = [
|
||||||
this.seriesService.updateMetadata(this.metadata, this.collectionTags)
|
this.seriesService.updateMetadata(this.metadata, this.collectionTags)
|
||||||
@ -502,6 +519,12 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addWebLink() {
|
||||||
|
this.metadata.webLinks += ',';
|
||||||
|
this.editSeriesForm.addControl('link' + (this.WebLinks.length - 1), new FormControl('', [Validators.required]));
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
updateCollections(tags: CollectionTag[]) {
|
updateCollections(tags: CollectionTag[]) {
|
||||||
this.collectionTags = tags;
|
this.collectionTags = tags;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
@ -71,5 +71,19 @@
|
|||||||
{{entity.id}}
|
{{entity.id}}
|
||||||
</app-icon-and-title>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
|
<ng-container *ngIf="WebLinks.length > 0">
|
||||||
|
<div class="vr d-none d-lg-block m-2"></div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<app-icon-and-title label="Links" [clickable]="false" fontClasses="fa-solid fa-link" title="Links">
|
||||||
|
<a class="me-1" [href]="link | safeHtml" *ngFor="let link of WebLinks" target="_blank" rel="noopener noreferrer" [title]="link">
|
||||||
|
<img width="24px" height="24px" #img class="lazyload img-placeholder"
|
||||||
|
src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
||||||
|
[attr.data-src]="imageService.getWebLinkImage(link)"
|
||||||
|
(error)="imageService.updateErroredWebLinkImage($event)"
|
||||||
|
aria-hidden="true">
|
||||||
|
</a>
|
||||||
|
</app-icon-and-title>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, inject } from '@angular/core';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||||
import { Chapter } from 'src/app/_models/chapter';
|
import { Chapter } from 'src/app/_models/chapter';
|
||||||
@ -9,6 +9,7 @@ import { MangaFormat } from 'src/app/_models/manga-format';
|
|||||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||||
import { Volume } from 'src/app/_models/volume';
|
import { Volume } from 'src/app/_models/volume';
|
||||||
import { SeriesService } from 'src/app/_services/series.service';
|
import { SeriesService } from 'src/app/_services/series.service';
|
||||||
|
import { ImageService } from 'src/app/_services/image.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-entity-info-cards',
|
selector: 'app-entity-info-cards',
|
||||||
@ -40,6 +41,7 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy {
|
|||||||
size: number = 0;
|
size: number = 0;
|
||||||
|
|
||||||
private readonly onDestroy: Subject<void> = new Subject();
|
private readonly onDestroy: Subject<void> = new Subject();
|
||||||
|
imageService = inject(ImageService);
|
||||||
|
|
||||||
get LibraryType() {
|
get LibraryType() {
|
||||||
return LibraryType;
|
return LibraryType;
|
||||||
@ -53,6 +55,10 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy {
|
|||||||
return AgeRating;
|
return AgeRating;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get WebLinks() {
|
||||||
|
return this.chapter.webLinks.split(',');
|
||||||
|
}
|
||||||
|
|
||||||
constructor(private utilityService: UtilityService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {}
|
constructor(private utilityService: UtilityService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, Inject, ChangeDetectionStrategy, ChangeDetectorRef, AfterContentChecked } from '@angular/core';
|
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, Inject, ChangeDetectionStrategy, ChangeDetectorRef, AfterContentChecked, inject } from '@angular/core';
|
||||||
import { FormGroup, FormControl } from '@angular/forms';
|
import { FormGroup, FormControl } from '@angular/forms';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
@ -117,7 +117,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||||||
downloadInProgress: boolean = false;
|
downloadInProgress: boolean = false;
|
||||||
|
|
||||||
itemSize: number = 10; // when 10 done, 16 loads
|
itemSize: number = 10; // when 10 done, 16 loads
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track by function for Volume to tell when to refresh card data
|
* Track by function for Volume to tell when to refresh card data
|
||||||
*/
|
*/
|
||||||
|
@ -2,6 +2,24 @@
|
|||||||
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
|
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="WebLinks as links">
|
||||||
|
<div class="row g-0 mt-2 mb-2" *ngIf="links.length > 0">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h5>Links</h5>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<img width="24px" height="24px" #img class="lazyload img-placeholder"
|
||||||
|
src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
||||||
|
[attr.data-src]="imageService.getWebLinkImage(link)"
|
||||||
|
(error)="imageService.updateErroredWebLinkImage($event)"
|
||||||
|
aria-hidden="true">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
|
||||||
<div class="row g-0" *ngIf="seriesMetadata.genres && seriesMetadata.genres.length > 0">
|
<div class="row g-0" *ngIf="seriesMetadata.genres && seriesMetadata.genres.length > 0">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<h5>Genres</h5>
|
<h5>Genres</h5>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ReaderService } from 'src/app/_services/reader.service';
|
import { ReaderService } from 'src/app/_services/reader.service';
|
||||||
import { TagBadgeCursor } from '../../../shared/tag-badge/tag-badge.component';
|
import { TagBadgeCursor } from '../../../shared/tag-badge/tag-badge.component';
|
||||||
@ -9,6 +9,7 @@ import { ReadingList } from '../../../_models/reading-list';
|
|||||||
import { Series } from '../../../_models/series';
|
import { Series } from '../../../_models/series';
|
||||||
import { SeriesMetadata } from '../../../_models/metadata/series-metadata';
|
import { SeriesMetadata } from '../../../_models/metadata/series-metadata';
|
||||||
import { MetadataService } from '../../../_services/metadata.service';
|
import { MetadataService } from '../../../_services/metadata.service';
|
||||||
|
import { ImageService } from 'src/app/_services/image.service';
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -17,7 +18,7 @@ import { MetadataService } from '../../../_services/metadata.service';
|
|||||||
styleUrls: ['./series-metadata-detail.component.scss'],
|
styleUrls: ['./series-metadata-detail.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
export class SeriesMetadataDetailComponent implements OnChanges {
|
||||||
|
|
||||||
@Input() seriesMetadata!: SeriesMetadata;
|
@Input() seriesMetadata!: SeriesMetadata;
|
||||||
@Input() hasReadingProgress: boolean = false;
|
@Input() hasReadingProgress: boolean = false;
|
||||||
@ -30,6 +31,8 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
|||||||
isCollapsed: boolean = true;
|
isCollapsed: boolean = true;
|
||||||
hasExtendedProperites: boolean = false;
|
hasExtendedProperites: boolean = false;
|
||||||
|
|
||||||
|
imageService = inject(ImageService);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Html representation of Series Summary
|
* Html representation of Series Summary
|
||||||
*/
|
*/
|
||||||
@ -47,6 +50,10 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
|||||||
return FilterQueryParam;
|
return FilterQueryParam;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get WebLinks() {
|
||||||
|
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) {
|
||||||
@ -70,9 +77,6 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
|||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleView() {
|
toggleView() {
|
||||||
this.isCollapsed = !this.isCollapsed;
|
this.isCollapsed = !this.isCollapsed;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
BIN
UI/Web/src/assets/images/broken-black-32x32.png
Normal file
BIN
UI/Web/src/assets/images/broken-black-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 822 B |
BIN
UI/Web/src/assets/images/broken-white-32x32.png
Normal file
BIN
UI/Web/src/assets/images/broken-white-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 581 B |
52
openapi.json
52
openapi.json
@ -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.0"
|
"version": "0.7.2.3"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -2058,6 +2058,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Image/web-link": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Image"
|
||||||
|
],
|
||||||
|
"summary": "Returns the image associated with a web-link",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "url",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "apiKey",
|
||||||
|
"in": "query",
|
||||||
|
"description": "",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Image/cover-upload": {
|
"/api/Image/cover-upload": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -10556,6 +10586,11 @@
|
|||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
},
|
},
|
||||||
|
"webLinks": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Comma-separated link of urls to external services that have some relation to the Chapter",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"people": {
|
"people": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@ -10710,6 +10745,11 @@
|
|||||||
"avgHoursToRead": {
|
"avgHoursToRead": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"webLinks": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Comma-separated link of urls to external services that have some relation to the Chapter",
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@ -13708,6 +13748,11 @@
|
|||||||
"publicationStatus": {
|
"publicationStatus": {
|
||||||
"$ref": "#/components/schemas/PublicationStatus"
|
"$ref": "#/components/schemas/PublicationStatus"
|
||||||
},
|
},
|
||||||
|
"webLinks": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A Comma-separated list of strings representing links from the series",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"languageLocked": {
|
"languageLocked": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@ -13907,6 +13952,11 @@
|
|||||||
"publicationStatus": {
|
"publicationStatus": {
|
||||||
"$ref": "#/components/schemas/PublicationStatus"
|
"$ref": "#/components/schemas/PublicationStatus"
|
||||||
},
|
},
|
||||||
|
"webLinks": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A comma-separated list of Urls",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"languageLocked": {
|
"languageLocked": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user