diff --git a/DBConnection/Extensions/BuilderExtensions.cs b/DBConnection/Extensions/BuilderExtensions.cs index 4601081..40265c0 100644 --- a/DBConnection/Extensions/BuilderExtensions.cs +++ b/DBConnection/Extensions/BuilderExtensions.cs @@ -18,14 +18,14 @@ public static class BuilderExtensions string dbConnectionString = config.GetConnectionString("DefaultConnection"); collection.AddDbContext(opt => { - opt.UseNpgsql(dbConnectionString); + opt.UseSqlite(dbConnectionString); }); Type[] repositories = Assembly.GetExecutingAssembly().GetTypes() .Where(t => t.IsClass && !t.IsAbstract && (t.Namespace?.Contains(nameof(DBConnection.Repositories)) ?? false) && typeof(IRepository).IsAssignableFrom(t)).ToArray(); foreach (var repo in repositories) { var repoInterface = repo.GetInterfaces() - .FirstOrDefault(repoInterface => typeof(IRepository).IsAssignableFrom(repoInterface) && repoInterface != typeof(IRepository)); + .FirstOrDefault(repoInterface => typeof(IRepository).IsAssignableFrom(repoInterface) && repoInterface != typeof(IRepository) && !repoInterface.IsGenericType); if (repoInterface != null) { collection.AddScoped(repoInterface, repo); diff --git a/DBConnection/Migrations/20220715030913_Initial.Designer.cs b/DBConnection/Migrations/20220715030913_Initial.Designer.cs new file mode 100644 index 0000000..1afa21b --- /dev/null +++ b/DBConnection/Migrations/20220715030913_Initial.Designer.cs @@ -0,0 +1,250 @@ +// +using System; +using DBConnection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DBConnection.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20220715030913_Initial")] + partial class Initial + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.7"); + + modelBuilder.Entity("DBConnection.Models.Author", b => + { + b.Property("Url") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Url"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("DBConnection.Models.Chapter", b => + { + b.Property("Url") + .HasColumnType("TEXT"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DatePosted") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NovelUrl") + .HasColumnType("TEXT"); + + b.Property("RawContent") + .HasColumnType("TEXT"); + + b.HasKey("Url"); + + b.HasIndex("NovelUrl"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("DBConnection.Models.Novel", b => + { + b.Property("Url") + .HasColumnType("TEXT"); + + b.Property("AuthorUrl") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DatePosted") + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Url"); + + b.HasIndex("AuthorUrl"); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("DBConnection.Models.Tag", b => + { + b.Property("TagValue") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.HasKey("TagValue"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("DBConnection.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("DBConnection.Models.UserNovel", b => + { + b.Property("NovelUrl") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LastChapterRead") + .HasColumnType("INTEGER"); + + b.HasKey("NovelUrl", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("UserNovels"); + }); + + modelBuilder.Entity("NovelTag", b => + { + b.Property("NovelsUrl") + .HasColumnType("TEXT"); + + b.Property("TagsTagValue") + .HasColumnType("TEXT"); + + b.HasKey("NovelsUrl", "TagsTagValue"); + + b.HasIndex("TagsTagValue"); + + b.ToTable("NovelTag"); + }); + + modelBuilder.Entity("DBConnection.Models.Chapter", b => + { + b.HasOne("DBConnection.Models.Novel", null) + .WithMany("Chapters") + .HasForeignKey("NovelUrl"); + }); + + modelBuilder.Entity("DBConnection.Models.Novel", b => + { + b.HasOne("DBConnection.Models.Author", "Author") + .WithMany("Novels") + .HasForeignKey("AuthorUrl"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("DBConnection.Models.UserNovel", b => + { + b.HasOne("DBConnection.Models.Novel", "Novel") + .WithMany() + .HasForeignKey("NovelUrl") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DBConnection.Models.User", "User") + .WithMany("WatchedNovels") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NovelTag", b => + { + b.HasOne("DBConnection.Models.Novel", null) + .WithMany() + .HasForeignKey("NovelsUrl") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DBConnection.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsTagValue") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DBConnection.Models.Author", b => + { + b.Navigation("Novels"); + }); + + modelBuilder.Entity("DBConnection.Models.Novel", b => + { + b.Navigation("Chapters"); + }); + + modelBuilder.Entity("DBConnection.Models.User", b => + { + b.Navigation("WatchedNovels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DBConnection/Migrations/20220715030913_Initial.cs b/DBConnection/Migrations/20220715030913_Initial.cs new file mode 100644 index 0000000..99b53f4 --- /dev/null +++ b/DBConnection/Migrations/20220715030913_Initial.cs @@ -0,0 +1,195 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DBConnection.Migrations +{ + public partial class Initial : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Authors", + columns: table => new + { + Url = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + DateCreated = table.Column(type: "TEXT", nullable: false), + DateModified = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Authors", x => x.Url); + }); + + migrationBuilder.CreateTable( + name: "Tags", + columns: table => new + { + TagValue = table.Column(type: "TEXT", nullable: false), + DateCreated = table.Column(type: "TEXT", nullable: false), + DateModified = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tags", x => x.TagValue); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Email = table.Column(type: "TEXT", nullable: false), + DateCreated = table.Column(type: "TEXT", nullable: false), + DateModified = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Novels", + columns: table => new + { + Url = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + AuthorUrl = table.Column(type: "TEXT", nullable: true), + LastUpdated = table.Column(type: "TEXT", nullable: false), + DatePosted = table.Column(type: "TEXT", nullable: false), + DateCreated = table.Column(type: "TEXT", nullable: false), + DateModified = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Novels", x => x.Url); + table.ForeignKey( + name: "FK_Novels_Authors_AuthorUrl", + column: x => x.AuthorUrl, + principalTable: "Authors", + principalColumn: "Url"); + }); + + migrationBuilder.CreateTable( + name: "Chapters", + columns: table => new + { + Url = table.Column(type: "TEXT", nullable: false), + ChapterNumber = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Content = table.Column(type: "TEXT", nullable: true), + RawContent = table.Column(type: "TEXT", nullable: true), + DatePosted = table.Column(type: "TEXT", nullable: false), + DateUpdated = table.Column(type: "TEXT", nullable: false), + NovelUrl = table.Column(type: "TEXT", nullable: true), + DateCreated = table.Column(type: "TEXT", nullable: false), + DateModified = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Chapters", x => x.Url); + table.ForeignKey( + name: "FK_Chapters_Novels_NovelUrl", + column: x => x.NovelUrl, + principalTable: "Novels", + principalColumn: "Url"); + }); + + migrationBuilder.CreateTable( + name: "NovelTag", + columns: table => new + { + NovelsUrl = table.Column(type: "TEXT", nullable: false), + TagsTagValue = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_NovelTag", x => new { x.NovelsUrl, x.TagsTagValue }); + table.ForeignKey( + name: "FK_NovelTag_Novels_NovelsUrl", + column: x => x.NovelsUrl, + principalTable: "Novels", + principalColumn: "Url", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_NovelTag_Tags_TagsTagValue", + column: x => x.TagsTagValue, + principalTable: "Tags", + principalColumn: "TagValue", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserNovels", + columns: table => new + { + UserId = table.Column(type: "INTEGER", nullable: false), + NovelUrl = table.Column(type: "TEXT", nullable: false), + LastChapterRead = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserNovels", x => new { x.NovelUrl, x.UserId }); + table.ForeignKey( + name: "FK_UserNovels_Novels_NovelUrl", + column: x => x.NovelUrl, + principalTable: "Novels", + principalColumn: "Url", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserNovels_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Chapters_NovelUrl", + table: "Chapters", + column: "NovelUrl"); + + migrationBuilder.CreateIndex( + name: "IX_Novels_AuthorUrl", + table: "Novels", + column: "AuthorUrl"); + + migrationBuilder.CreateIndex( + name: "IX_NovelTag_TagsTagValue", + table: "NovelTag", + column: "TagsTagValue"); + + migrationBuilder.CreateIndex( + name: "IX_UserNovels_UserId", + table: "UserNovels", + column: "UserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Chapters"); + + migrationBuilder.DropTable( + name: "NovelTag"); + + migrationBuilder.DropTable( + name: "UserNovels"); + + migrationBuilder.DropTable( + name: "Tags"); + + migrationBuilder.DropTable( + name: "Novels"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "Authors"); + } + } +} diff --git a/DBConnection/Migrations/AppDbContextModelSnapshot.cs b/DBConnection/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..3c23927 --- /dev/null +++ b/DBConnection/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,248 @@ +// +using System; +using DBConnection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DBConnection.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.7"); + + modelBuilder.Entity("DBConnection.Models.Author", b => + { + b.Property("Url") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Url"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("DBConnection.Models.Chapter", b => + { + b.Property("Url") + .HasColumnType("TEXT"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DatePosted") + .HasColumnType("TEXT"); + + b.Property("DateUpdated") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NovelUrl") + .HasColumnType("TEXT"); + + b.Property("RawContent") + .HasColumnType("TEXT"); + + b.HasKey("Url"); + + b.HasIndex("NovelUrl"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("DBConnection.Models.Novel", b => + { + b.Property("Url") + .HasColumnType("TEXT"); + + b.Property("AuthorUrl") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DatePosted") + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Url"); + + b.HasIndex("AuthorUrl"); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("DBConnection.Models.Tag", b => + { + b.Property("TagValue") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.HasKey("TagValue"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("DBConnection.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("DBConnection.Models.UserNovel", b => + { + b.Property("NovelUrl") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LastChapterRead") + .HasColumnType("INTEGER"); + + b.HasKey("NovelUrl", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("UserNovels"); + }); + + modelBuilder.Entity("NovelTag", b => + { + b.Property("NovelsUrl") + .HasColumnType("TEXT"); + + b.Property("TagsTagValue") + .HasColumnType("TEXT"); + + b.HasKey("NovelsUrl", "TagsTagValue"); + + b.HasIndex("TagsTagValue"); + + b.ToTable("NovelTag"); + }); + + modelBuilder.Entity("DBConnection.Models.Chapter", b => + { + b.HasOne("DBConnection.Models.Novel", null) + .WithMany("Chapters") + .HasForeignKey("NovelUrl"); + }); + + modelBuilder.Entity("DBConnection.Models.Novel", b => + { + b.HasOne("DBConnection.Models.Author", "Author") + .WithMany("Novels") + .HasForeignKey("AuthorUrl"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("DBConnection.Models.UserNovel", b => + { + b.HasOne("DBConnection.Models.Novel", "Novel") + .WithMany() + .HasForeignKey("NovelUrl") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DBConnection.Models.User", "User") + .WithMany("WatchedNovels") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NovelTag", b => + { + b.HasOne("DBConnection.Models.Novel", null) + .WithMany() + .HasForeignKey("NovelsUrl") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DBConnection.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsTagValue") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DBConnection.Models.Author", b => + { + b.Navigation("Novels"); + }); + + modelBuilder.Entity("DBConnection.Models.Novel", b => + { + b.Navigation("Chapters"); + }); + + modelBuilder.Entity("DBConnection.Models.User", b => + { + b.Navigation("WatchedNovels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DBConnection/Models/Author.cs b/DBConnection/Models/Author.cs index fb07f1b..792cdb6 100644 --- a/DBConnection/Models/Author.cs +++ b/DBConnection/Models/Author.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; namespace DBConnection.Models; @@ -7,5 +8,6 @@ public class Author : BaseEntity [Key] public string Url { get; set; } public string Name { get; set; } + [JsonIgnore] public List Novels { get; set; } } \ No newline at end of file diff --git a/DBConnection/Models/Chapter.cs b/DBConnection/Models/Chapter.cs index 5b1590d..38698a4 100644 --- a/DBConnection/Models/Chapter.cs +++ b/DBConnection/Models/Chapter.cs @@ -4,11 +4,11 @@ namespace DBConnection.Models; public class Chapter : BaseEntity { - [Key] public int ChapterNumber { get; set; } public string Name { get; set; } public string? Content { get; set; } public string? RawContent { get; set; } + [Key] public string Url { get; set; } public DateTime DatePosted { get; set; } public DateTime DateUpdated { get; set; } diff --git a/DBConnection/Repositories/AuthorRepository.cs b/DBConnection/Repositories/AuthorRepository.cs new file mode 100644 index 0000000..6f55f21 --- /dev/null +++ b/DBConnection/Repositories/AuthorRepository.cs @@ -0,0 +1,17 @@ +using DBConnection.Models; +using DBConnection.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace DBConnection.Repositories; + +public class AuthorRepository : BaseRepository, IAuthorRepository +{ + public AuthorRepository(AppDbContext dbContext) : base(dbContext) + { + } + + protected override IQueryable GetAllIncludedQueryable() + { + return DbContext.Authors.Include(i => i.Novels); + } +} \ No newline at end of file diff --git a/DBConnection/Repositories/BaseRepository.cs b/DBConnection/Repositories/BaseRepository.cs index 582b004..71ed45d 100644 --- a/DBConnection/Repositories/BaseRepository.cs +++ b/DBConnection/Repositories/BaseRepository.cs @@ -13,7 +13,8 @@ public abstract class BaseRepository : IRepository whe private object?[]? GetPrimaryKey(TEntityType entity) { var keyProperties = DbContext.Model.FindEntityType(typeof(TEntityType))?.FindPrimaryKey()?.Properties.Select(p => p.Name); - return keyProperties?.Select(p => entity.GetType().GetProperty(p)?.GetValue(entity, null)).ToArray(); + var ret = keyProperties?.Select(p => entity.GetType().GetProperty(p)?.GetValue(entity, null)).ToArray(); + return ret; } protected abstract IQueryable GetAllIncludedQueryable(); @@ -48,7 +49,7 @@ public abstract class BaseRepository : IRepository whe public virtual async Task GetIncluded(TEntityType entity) { - return await GetIncluded(dbEntity => GetPrimaryKey(dbEntity) == GetPrimaryKey(entity)); + return await GetIncluded(dbEntity => GetPrimaryKey(dbEntity).SequenceEqual(GetPrimaryKey(entity))); } public virtual async Task GetIncluded(Func predicate) diff --git a/DBConnection/Repositories/Interfaces/IAuthorRepository.cs b/DBConnection/Repositories/Interfaces/IAuthorRepository.cs new file mode 100644 index 0000000..6ed1c77 --- /dev/null +++ b/DBConnection/Repositories/Interfaces/IAuthorRepository.cs @@ -0,0 +1,8 @@ +using DBConnection.Models; + +namespace DBConnection.Repositories.Interfaces; + +public interface IAuthorRepository : IRepository +{ + +} \ No newline at end of file diff --git a/DBConnection/Repositories/Interfaces/ITagRepository.cs b/DBConnection/Repositories/Interfaces/ITagRepository.cs new file mode 100644 index 0000000..5f0e521 --- /dev/null +++ b/DBConnection/Repositories/Interfaces/ITagRepository.cs @@ -0,0 +1,8 @@ +using DBConnection.Models; + +namespace DBConnection.Repositories.Interfaces; + +public interface ITagRepository : IRepository +{ + +} \ No newline at end of file diff --git a/DBConnection/Repositories/NovelRepository.cs b/DBConnection/Repositories/NovelRepository.cs index 270d41d..bfa2548 100644 --- a/DBConnection/Repositories/NovelRepository.cs +++ b/DBConnection/Repositories/NovelRepository.cs @@ -6,9 +6,32 @@ namespace DBConnection.Repositories; public class NovelRepository : BaseRepository, INovelRepository { - - public NovelRepository(AppDbContext dbContext) : base(dbContext) + private readonly IAuthorRepository _authorRepository; + private readonly ITagRepository _tagRepository; + public NovelRepository(AppDbContext dbContext, IAuthorRepository authorRepository, ITagRepository tagRepository) : base(dbContext) { + _authorRepository = authorRepository; + _tagRepository = tagRepository; + } + + public override async Task Upsert(Novel entity) + { + var dbEntity = await GetIncluded(entity) ?? entity; + dbEntity.Author = await _authorRepository.GetIncluded(entity.Author) ?? entity.Author; + List newTags = new List(); + foreach (var tag in dbEntity.Tags) + { + newTags.Add(await _tagRepository.GetIncluded(tag) ?? tag); + } + dbEntity.Tags.Clear(); + dbEntity.Tags = newTags; + if (DbContext.Entry(dbEntity).State == EntityState.Detached) + { + DbContext.Add(dbEntity); + } + + await DbContext.SaveChangesAsync(); + return dbEntity; } protected override IQueryable GetAllIncludedQueryable() diff --git a/DBConnection/Repositories/TagRepository.cs b/DBConnection/Repositories/TagRepository.cs new file mode 100644 index 0000000..5a71f99 --- /dev/null +++ b/DBConnection/Repositories/TagRepository.cs @@ -0,0 +1,17 @@ +using DBConnection.Models; +using DBConnection.Repositories.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace DBConnection.Repositories; + +public class TagRepository : BaseRepository, ITagRepository +{ + public TagRepository(AppDbContext dbContext) : base(dbContext) + { + } + + protected override IQueryable GetAllIncludedQueryable() + { + return DbContext.Tags.Include(i => i.Novels); + } +} \ No newline at end of file diff --git a/Shared/AccessLayers/ApiAccessLayer.cs b/Shared/AccessLayers/ApiAccessLayer.cs index 92a97b5..4410178 100644 --- a/Shared/AccessLayers/ApiAccessLayer.cs +++ b/Shared/AccessLayers/ApiAccessLayer.cs @@ -70,7 +70,7 @@ public abstract class ApiAccessLayer return await SendRequest(message); } - protected async Task> SendGet(string endpoint, HttpMethod method, Dictionary? queryParams = null, object? data = null) + protected async Task> SendRequest(string endpoint, HttpMethod method, Dictionary? queryParams = null, object? data = null) { HttpRequestMessage message = CreateRequestMessage(endpoint, method, queryParams, data); return await SendRequest(message); diff --git a/WebNovelPortalAPI/Controllers/NovelController.cs b/WebNovelPortalAPI/Controllers/NovelController.cs index 81f9418..2ae8eff 100644 --- a/WebNovelPortalAPI/Controllers/NovelController.cs +++ b/WebNovelPortalAPI/Controllers/NovelController.cs @@ -3,8 +3,13 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using DBConnection; +using DBConnection.Models; +using DBConnection.Repositories; +using DBConnection.Repositories.Interfaces; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using WebNovelPortalAPI.DTO; +using WebNovelPortalAPI.Scrapers; namespace WebNovelPortalAPI.Controllers { @@ -12,11 +17,42 @@ namespace WebNovelPortalAPI.Controllers [ApiController] public class NovelController : ControllerBase { - private readonly AppDbContext _context; + private readonly INovelRepository _novelRepository; + private readonly IEnumerable _scrapers; - public NovelController(AppDbContext context) + public NovelController(IEnumerable scrapers, INovelRepository novelRepository) { - _context = context; + _scrapers = scrapers; + _novelRepository = novelRepository; + } + + private IScraper? MatchScraper(string novelUrl) + { + return _scrapers.FirstOrDefault(i => i.MatchesUrl(novelUrl)); + } + + [HttpPost] + [Route("scrapeNovel")] + public async Task ScrapeNovel(ScrapeNovelRequest request) + { + var scraper = MatchScraper(request.NovelUrl); + if (scraper == null) + { + return BadRequest("Invalid url, no valid scraper configured"); + } + + Novel novel; + try + { + novel = scraper.ScrapeNovel(request.NovelUrl); + } + catch (Exception e) + { + return StatusCode(500, e); + } + + var novelUpload = await _novelRepository.Upsert(novel); + return Ok(novelUpload); } } } diff --git a/WebNovelPortalAPI/Controllers/WeatherForecastController.cs b/WebNovelPortalAPI/Controllers/WeatherForecastController.cs deleted file mode 100644 index 1e5b840..0000000 --- a/WebNovelPortalAPI/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace WebNovelPortalAPI.Controllers; - -[ApiController] -[Route("[controller]")] -public class WeatherForecastController : ControllerBase -{ - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateTime.Now.AddDays(index), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } -} \ No newline at end of file diff --git a/WebNovelPortalAPI/DTO/ScrapeNovelRequest.cs b/WebNovelPortalAPI/DTO/ScrapeNovelRequest.cs new file mode 100644 index 0000000..9878bd9 --- /dev/null +++ b/WebNovelPortalAPI/DTO/ScrapeNovelRequest.cs @@ -0,0 +1,6 @@ +namespace WebNovelPortalAPI.DTO; + +public class ScrapeNovelRequest +{ + public string NovelUrl { get; set; } +} \ No newline at end of file diff --git a/WebNovelPortalAPI/Extensions/ScraperExtensions.cs b/WebNovelPortalAPI/Extensions/ScraperExtensions.cs new file mode 100644 index 0000000..bec3e06 --- /dev/null +++ b/WebNovelPortalAPI/Extensions/ScraperExtensions.cs @@ -0,0 +1,18 @@ +using System.Reflection; +using WebNovelPortalAPI.Scrapers; + +namespace WebNovelPortalAPI.Extensions; + +public static class ScraperExtensions +{ + public static void AddScrapers(this IServiceCollection services) + { + Type[] types = Assembly.GetExecutingAssembly().GetTypes().Where(t => + t.IsClass && typeof(IScraper).IsAssignableFrom(t) && (t.Namespace?.Contains(nameof(Scrapers)) ?? false)) + .ToArray(); + foreach (var t in types) + { + services.AddScoped(typeof(IScraper), t); + } + } +} \ No newline at end of file diff --git a/WebNovelPortalAPI/Program.cs b/WebNovelPortalAPI/Program.cs index afe617f..559d52f 100644 --- a/WebNovelPortalAPI/Program.cs +++ b/WebNovelPortalAPI/Program.cs @@ -1,12 +1,19 @@ using DBConnection; using DBConnection.Extensions; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; +using WebNovelPortalAPI.Extensions; +using WebNovelPortalAPI.Scrapers; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddDbServices(builder.Configuration); -builder.Services.AddControllers(); +builder.Services.AddScrapers(); +builder.Services.AddControllers().AddNewtonsoftJson(opt => +{ + opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; +}); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/WebNovelPortalAPI/Scrapers/AbstractScraper.cs b/WebNovelPortalAPI/Scrapers/AbstractScraper.cs deleted file mode 100644 index 4c8de6e..0000000 --- a/WebNovelPortalAPI/Scrapers/AbstractScraper.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace WebNovelPortalAPI.Scrapers; - -public class AbstractScraper -{ - -} \ No newline at end of file diff --git a/WebNovelPortalAPI/Scrapers/IScraper.cs b/WebNovelPortalAPI/Scrapers/IScraper.cs index a7efb86..f0b8ce6 100644 --- a/WebNovelPortalAPI/Scrapers/IScraper.cs +++ b/WebNovelPortalAPI/Scrapers/IScraper.cs @@ -4,6 +4,8 @@ namespace WebNovelPortalAPI.Scrapers; public interface IScraper { + public bool MatchesUrl(string url); public Novel ScrapeNovel(string url); - + public string? ScrapeChapterContent(string chapterUrl); + } \ No newline at end of file diff --git a/WebNovelPortalAPI/Scrapers/KakuyomuScraper.cs b/WebNovelPortalAPI/Scrapers/KakuyomuScraper.cs new file mode 100644 index 0000000..6249b54 --- /dev/null +++ b/WebNovelPortalAPI/Scrapers/KakuyomuScraper.cs @@ -0,0 +1,102 @@ +using System.Reflection.Metadata; +using System.Text.RegularExpressions; +using DBConnection.Models; +using HtmlAgilityPack; + +namespace WebNovelPortalAPI.Scrapers; + +public class KakuyomuScraper : IScraper +{ + private const string UrlPattern = @"https?:\/\/kakuyomu\.jp\/works\/\d+\/?"; + private const string BaseUrl = "https://kakuyomu.jp"; + public bool MatchesUrl(string url) + { + var regex = new Regex(UrlPattern, RegexOptions.IgnoreCase); + return regex.IsMatch(url); + } + + private string GetNovelTitle(HtmlDocument document) + { + var xpath = @"//*[@id='workTitle']/a"; + return document.DocumentNode.SelectSingleNode(xpath).InnerText; + } + + private Author GetAuthor(HtmlDocument document) + { + var nameXPath = @"//*[@id='workAuthor-activityName']/a"; + var urlXPath = @"//*[@id='workAuthor-activityName']/a"; + var authorName = document.DocumentNode.SelectSingleNode(nameXPath).InnerText; + var authorUrl = document.DocumentNode.SelectSingleNode(urlXPath).Attributes["href"].Value; + Author author = new Author + { + Name = authorName, + Url = $"{BaseUrl + authorUrl}" + }; + return author; + + } + + private List GetChapters(HtmlDocument document) + { + var urlxpath = @"//a[@class='widget-toc-episode-episodeTitle']"; + var namexpath = @"span"; + var urlnodes = document.DocumentNode.SelectNodes(urlxpath); + var chapters = urlnodes.Select((node, i) => new Chapter + { + ChapterNumber = i + 1, + Url = $"{BaseUrl}{node.Attributes["href"].Value}", + Name = node.SelectSingleNode(namexpath).InnerText + }); + + return chapters.ToList(); + } + + private List GetTags(HtmlDocument document) + { + var xpath = @"//span[@itemprop='keywords']/a"; + var nodes = document.DocumentNode.SelectNodes(xpath); + return nodes.Select(node => new Tag + { + TagValue = node.InnerText + }).ToList(); + } + + private DateTime GetPostedDate(HtmlDocument document) + { + var xpath = @"//time[@itemprop='datePublished']"; + return DateTime.Parse(document.DocumentNode.SelectSingleNode(xpath).InnerText); + } + + private DateTime GetLastUpdatedDate(HtmlDocument document) + { + var xpath = @"//time[@itemprop='dateModified']"; + return DateTime.Parse(document.DocumentNode.SelectSingleNode(xpath).InnerText); + } + + public Novel ScrapeNovel(string url) + { + Novel novel = new Novel(); + var web = new HtmlWeb(); + var doc = web.Load(url); + if (doc == null) + { + throw new Exception("Error parsing document"); + } + + return new Novel + { + Author = GetAuthor(doc), + Chapters = GetChapters(doc), + DatePosted = GetPostedDate(doc), + LastUpdated = GetLastUpdatedDate(doc), + Tags = GetTags(doc), + Title = GetNovelTitle(doc), + Url = url + }; + } + + public string? ScrapeChapterContent(string chapterUrl) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/WebNovelPortalAPI/TranslationEngines/ITranslationEngine.cs b/WebNovelPortalAPI/TranslationEngines/ITranslationEngine.cs new file mode 100644 index 0000000..f1e9551 --- /dev/null +++ b/WebNovelPortalAPI/TranslationEngines/ITranslationEngine.cs @@ -0,0 +1,6 @@ +namespace WebNovelPortalAPI.TranslationEngines; + +public interface ITranslationEngine +{ + public string Translate(string text); +} \ No newline at end of file diff --git a/WebNovelPortalAPI/WeatherForecast.cs b/WebNovelPortalAPI/WeatherForecast.cs deleted file mode 100644 index b691c5f..0000000 --- a/WebNovelPortalAPI/WeatherForecast.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace WebNovelPortalAPI; - -public class WeatherForecast -{ - public DateTime Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int) (TemperatureC / 0.5556); - - public string? Summary { get; set; } -} \ No newline at end of file diff --git a/WebNovelPortalAPI/WebNovelPortalAPI.csproj b/WebNovelPortalAPI/WebNovelPortalAPI.csproj index b1294b3..cdd980a 100644 --- a/WebNovelPortalAPI/WebNovelPortalAPI.csproj +++ b/WebNovelPortalAPI/WebNovelPortalAPI.csproj @@ -7,6 +7,8 @@ + + diff --git a/WebNovelPortalAPI/appsettings.json b/WebNovelPortalAPI/appsettings.json index 29591d2..373723a 100644 --- a/WebNovelPortalAPI/appsettings.json +++ b/WebNovelPortalAPI/appsettings.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "DefaultConnection": "null" + "DefaultConnection": "Data Source=/home/m/Documents/WebNovelPortal/WebNovelPortalAPI/test_db" }, "AllowedHosts": "*" }