diff --git a/Readme.md b/Readme.md index a36c69e8..7176aaa9 100644 --- a/Readme.md +++ b/Readme.md @@ -14,6 +14,7 @@ This also includes source code snippets. Highlighting is done via [highlight.js] ## Features - **Modern Markdown Editor** - Write blog posts with a feature-rich markdown editor +- **Markdown Import** - Automatically import blog posts from external repositories (e.g., GitHub) - **Bookmarks** - Allow readers to save their favorite articles - **Drafts** - Save work in progress and continue later - **Scheduled Publishing** - Plan ahead and publish automatically @@ -41,6 +42,7 @@ This also includes source code snippets. Highlighting is done via [highlight.js] - [Storage Provider](./docs/Storage/Readme.md) - [Media Upload](./docs/Media/Readme.md) - [Search Engine Optimization (SEO)](./docs/SEO/Readme.md) +- [Markdown Import](./docs/Features/MarkdownImport/Readme.md) - [Advanced Features](./docs/Features/AdvancedFeatures.md) ## Installation diff --git a/docs/Features/MarkdownImport/Readme.md b/docs/Features/MarkdownImport/Readme.md new file mode 100644 index 00000000..40ab0c9f --- /dev/null +++ b/docs/Features/MarkdownImport/Readme.md @@ -0,0 +1,227 @@ +# Markdown Import + +The Markdown Import feature allows you to automatically import blog posts from external sources (such as GitHub repositories) by periodically scanning for markdown files. This enables you to author and version control your blog posts externally while having them automatically synchronized to your blog. + +## Overview + +The Markdown Import job runs every 15 minutes (when enabled) and: +1. Fetches markdown files from the configured source URL +2. Parses metadata from each file's header section +3. Creates new blog posts or updates existing ones based on the `ExternalId` +4. Clears the cache to reflect changes + +## Configuration + +Add the following section to your `appsettings.json` file: + +```json +{ + "MarkdownImport": { + "Enabled": true, + "SourceType": "FlatDirectory", + "Url": "https://raw.githubusercontent.com/yourusername/blog-posts/main/posts/" + } +} +``` + +### Configuration Properties + +| Property | Type | Description | Default | +|----------|------|-------------|---------| +| `Enabled` | boolean | Enable or disable the markdown import feature | `false` | +| `SourceType` | string | Type of source provider (currently only `FlatDirectory` is supported) | `"FlatDirectory"` | +| `Url` | string | Base URL where markdown files are located | `""` | + +## Markdown File Format + +Each markdown file must follow this three-section format, with sections separated by `----------`: + +```markdown +---------- +id: unique-blog-post-id +title: Your Blog Post Title +tags: tag1, tag2, tag3 +image: https://example.com/preview-image.webp +fallbackimage: https://example.com/fallback-image.jpg +published: true +updatedDate: 2026-01-25T20:30:00Z +authorName: John Doe +---------- +This is the **short description** of your blog post. +It can contain *markdown* formatting and will be displayed in blog post previews. +---------- +This is the main content of your blog post. + +## You can use headings + +- Bullet points +- Code blocks +- Images +- All markdown features supported by the blog +``` + +### Metadata Fields + +#### Required Fields + +- **id**: Unique identifier for the blog post (used to track and update posts). Must be unique across all markdown files. Example: `my-first-post` +- **title**: The title of the blog post +- **image**: URL to the preview image (used in blog post cards and social media) +- **published**: Boolean value (`true` or `false`) indicating whether the post should be published + +#### Optional Fields + +- **tags**: Comma-separated list of tags +- **fallbackimage**: URL to a fallback image (used if the primary image fails to load) +- **updatedDate**: ISO 8601 formatted date (e.g., `2026-01-25T20:30:00Z`). If not provided, current time is used +- **authorName**: Name of the author. Useful when `UseMultiAuthorMode` is enabled + +### Content Sections + +After the metadata header, the file must contain two content sections: + +1. **Short Description** (first section after header): A brief summary shown in blog post listings +2. **Main Content** (second section after header): The full blog post content + +Both sections support full markdown syntax. + +## How It Works + +### Import Process + +1. The job fetches all `.md` files from the configured URL +2. Files are processed in alphabetical order +3. For each file: + - The markdown is parsed into metadata, short description, and content + - The system checks if a blog post with the same `ExternalId` exists + - If it exists, the post is updated with new content + - If it doesn't exist, a new blog post is created +4. After successful imports, the cache is cleared + +### Manual Import Trigger + +In addition to the automatic 15-minute schedule, you can manually trigger an import from the **Settings** page in the admin area: + +1. Log in to your blog +2. Navigate to **Settings** (when logged in) +3. Click the **"Run Import"** button in the Markdown Import row +4. The import job will start immediately + +This is useful when: +- You've just pushed new markdown files and want them imported right away +- You're testing the import configuration +- You need to re-import files after making corrections + +### Update Behavior + +When a markdown file is re-imported (same `id` as an existing post): +- All content is updated from the markdown file +- The `ExternalId` remains unchanged +- **⚠️ Manual edits made through the blog UI will be overwritten** + +**Critical Warning**: If you edit an imported blog post through the blog's UI (using the built-in editor), those changes will be **permanently lost** the next time the import job runs (either automatically every 15 minutes or when manually triggered). + +**Best Practice**: Always treat your external markdown repository as the **single source of truth** for imported posts. Make all edits to imported posts in your external repository, not in the blog UI. + +If you need to stop auto-importing a specific post while retaining your manual edits: +1. Remove the markdown file from the external source, OR +2. Change the `id` field in the markdown file (this will create a new post on next import) +3. The original imported post (with your manual edits) will remain unchanged in the blog + +### Error Handling + +The import job is designed to be resilient: +- If a file fails to parse, an error is logged and the job continues with other files +- If the source URL is unreachable, the error is logged and the job completes without changes +- Invalid field values are logged as warnings but won't crash the job + +## Example Workflows + +### GitHub Repository Setup + +1. Create a repository for your blog posts (e.g., `blog-posts`) +2. Create a `posts/` directory +3. Add markdown files following the format above +4. Configure your blog's `appsettings.json` to point to the raw GitHub URL: + +```json +{ + "MarkdownImport": { + "Enabled": true, + "SourceType": "FlatDirectory", + "Url": "https://raw.githubusercontent.com/yourusername/blog-posts/main/posts/" + } +} +``` + +### Example Markdown File + +File: `2026-01-my-first-imported-post.md` + +```markdown +---------- +id: 2026-01-my-first-imported-post +title: Getting Started with Markdown Import +tags: tutorial, markdown, automation +image: https://images.unsplash.com/photo-1499750310107-5fef28a66643 +fallbackimage: https://via.placeholder.com/800x400 +published: true +updatedDate: 2026-01-25T10:00:00Z +authorName: Jane Developer +---------- +Learn how to use the markdown import feature to manage your blog posts in a Git repository. +This short description appears in blog listings. +---------- +# Introduction + +This is the full blog post content. You can use any markdown syntax here. + +## Why Use Markdown Import? + +- Version control your blog posts with Git +- Write in your favorite editor +- Collaborate with others using pull requests +- Automate your blogging workflow + +## Code Example + +```csharp +public class BlogPost +{ + public string Title { get; set; } + public string Content { get; set; } +} +``` + +That's all there is to it! + +## Troubleshooting + +### Posts Not Importing + +1. Check that `Enabled` is set to `true` in configuration +2. Verify the `Url` is accessible and returns a directory listing with `.md` files +3. Check application logs for error messages +4. Ensure markdown files follow the correct format + +### Parsing Errors + +Common issues: +- Missing required fields (`id`, `title`, `image`, `published`) +- Malformed header section (missing `----------` delimiters) +- Invalid date format in `updatedDate` field +- Empty content sections + +Check the application logs for specific error messages indicating which file and what field caused the issue. + +### Updates Not Reflecting + +- The job runs every 15 minutes, so changes may take time to appear +- Check that the `id` field in your markdown matches the `ExternalId` of the existing post +- Clear the blog cache manually if needed + +## Limitations + +- **Flat Directory Only**: Currently only supports flat directory structures (all files in one directory) +- **Public URLs**: The URL must be publicly accessible (no authentication support yet) +- **No Conflict Resolution**: External source is always the source of truth; manual edits are overwritten diff --git a/docs/Setup/Configuration.md b/docs/Setup/Configuration.md index 6b96e17a..c4926bb4 100644 --- a/docs/Setup/Configuration.md +++ b/docs/Setup/Configuration.md @@ -67,7 +67,12 @@ The appsettings.json file has a lot of options to customize the content of the b "ContainerName": "", "CdnEndpoint": "" }, - "UseMultiAuthorMode": false + "UseMultiAuthorMode": false, + "MarkdownImport": { + "Enabled": false, + "SourceType": "FlatDirectory", + "Url": "" + } } ``` @@ -113,3 +118,7 @@ The appsettings.json file has a lot of options to customize the content of the b | ContainerName | string | The container name for the image storage provider | | CdnEndpoint | string | Optional CDN endpoint to use for uploaded images. If set, the blog will return this URL instead of the storage account URL for uploaded assets. | | UseMultiAuthorMode | boolean | The default value is `false`. If set to `true` then author name will be associated with blog posts at the time of creation. This author name will be fetched from the identity provider's `name` or `nickname` or `preferred_username` claim property. | +| [MarkdownImport](./../Features/MarkdownImport/Readme.md) | node | Configuration for the markdown import feature. If left empty or `Enabled` is `false`, the feature is disabled. | +| Enabled | boolean | Enable or disable automatic markdown import from external sources | +| SourceType | string | Type of the markdown source (currently only `FlatDirectory` is supported) | +| Url | string | Base URL where markdown files are located | diff --git a/src/LinkDotNet.Blog.Domain/BlogPost.cs b/src/LinkDotNet.Blog.Domain/BlogPost.cs index 9d0319f1..b358f3fc 100644 --- a/src/LinkDotNet.Blog.Domain/BlogPost.cs +++ b/src/LinkDotNet.Blog.Domain/BlogPost.cs @@ -40,6 +40,8 @@ public sealed partial class BlogPost : Entity public string? AuthorName { get; private set; } + public string? ExternalId { get; private set; } + private string GenerateSlug() { if (string.IsNullOrWhiteSpace(Title)) @@ -95,7 +97,8 @@ public static BlogPost Create( DateTime? scheduledPublishDate = null, IEnumerable? tags = null, string? previewImageUrlFallback = null, - string? authorName = null) + string? authorName = null, + string? externalId = null) { if (scheduledPublishDate is not null && isPublished) { @@ -116,7 +119,8 @@ public static BlogPost Create( IsPublished = isPublished, Tags = tags?.Select(t => t.Trim()).ToImmutableArray() ?? [], ReadingTimeInMinutes = ReadingTimeCalculator.CalculateReadingTime(content), - AuthorName = authorName + AuthorName = authorName, + ExternalId = externalId }; return blogPost; @@ -148,5 +152,6 @@ public void Update(BlogPost from) Tags = from.Tags; ReadingTimeInMinutes = from.ReadingTimeInMinutes; AuthorName = from.AuthorName; + ExternalId = from.ExternalId; } } diff --git a/src/LinkDotNet.Blog.Domain/MarkdownImport/MarkdownContent.cs b/src/LinkDotNet.Blog.Domain/MarkdownImport/MarkdownContent.cs new file mode 100644 index 00000000..77087473 --- /dev/null +++ b/src/LinkDotNet.Blog.Domain/MarkdownImport/MarkdownContent.cs @@ -0,0 +1,6 @@ +namespace LinkDotNet.Blog.Domain.MarkdownImport; + +public sealed record MarkdownContent( + MarkdownMetadata Metadata, + string ShortDescription, + string Content); diff --git a/src/LinkDotNet.Blog.Domain/MarkdownImport/MarkdownMetadata.cs b/src/LinkDotNet.Blog.Domain/MarkdownImport/MarkdownMetadata.cs new file mode 100644 index 00000000..c667a63f --- /dev/null +++ b/src/LinkDotNet.Blog.Domain/MarkdownImport/MarkdownMetadata.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; + +namespace LinkDotNet.Blog.Domain.MarkdownImport; + +public sealed record MarkdownMetadata( + string Id, + string Title, + string Image, + bool Published, + IReadOnlyCollection Tags, + string? FallbackImage, + DateTime? UpdatedDate, + string? AuthorName); diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125204524_AddExternalIdToBlogPost.Designer.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125204524_AddExternalIdToBlogPost.Designer.cs new file mode 100644 index 00000000..ff5f2768 --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125204524_AddExternalIdToBlogPost.Designer.cs @@ -0,0 +1,282 @@ +// +using System; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LinkDotNet.Blog.Web.Migrations +{ + [DbContext(typeof(BlogDbContext))] + [Migration("20260125204524_AddExternalIdToBlogPost")] + partial class AddExternalIdToBlogPost + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PreviewImageUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("PreviewImageUrlFallback") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReadingTimeInMinutes") + .HasColumnType("INTEGER"); + + b.Property("ScheduledPublishDate") + .HasColumnType("TEXT"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsPublished", "UpdatedDate") + .IsDescending(false, true) + .HasDatabaseName("IX_BlogPosts_IsPublished_UpdatedDate"); + + b.ToTable("BlogPosts"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("BlogPostId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Clicks") + .HasColumnType("INTEGER"); + + b.Property("DateClicked") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BlogPostRecords"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BlogPostTemplates"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.ProfileInformationEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ProfileInformationEntries"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.ShortCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ShortCodes"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.SimilarBlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("SimilarBlogPostIds") + .IsRequired() + .HasMaxLength(1350) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SimilarBlogPosts"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ProficiencyLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.Talk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Place") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PresentationTitle") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Talks"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.UserRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("DateClicked") + .HasColumnType("TEXT"); + + b.Property("UrlClicked") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserRecords"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125204524_AddExternalIdToBlogPost.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125204524_AddExternalIdToBlogPost.cs new file mode 100644 index 00000000..c63f007e --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125204524_AddExternalIdToBlogPost.cs @@ -0,0 +1,33 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LinkDotNet.Blog.Web.Migrations; + +/// +public partial class AddExternalIdToBlogPost : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.AddColumn( + name: "ExternalId", + table: "BlogPosts", + type: "TEXT", + maxLength: 256, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.DropColumn( + name: "ExternalId", + table: "BlogPosts"); + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125214130_AddUniqueIndexOnExternalId.Designer.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125214130_AddUniqueIndexOnExternalId.Designer.cs new file mode 100644 index 00000000..4fa2999d --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125214130_AddUniqueIndexOnExternalId.Designer.cs @@ -0,0 +1,287 @@ +// +using System; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LinkDotNet.Blog.Infrastructure.Migrations +{ + [DbContext(typeof(BlogDbContext))] + [Migration("20260125214130_AddUniqueIndexOnExternalId")] + partial class AddUniqueIndexOnExternalId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsPublished") + .HasColumnType("INTEGER"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PreviewImageUrl") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("PreviewImageUrlFallback") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReadingTimeInMinutes") + .HasColumnType("INTEGER"); + + b.Property("ScheduledPublishDate") + .HasColumnType("TEXT"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasDatabaseName("IX_BlogPosts_ExternalId") + .HasFilter("ExternalId IS NOT NULL"); + + b.HasIndex("IsPublished", "UpdatedDate") + .IsDescending(false, true) + .HasDatabaseName("IX_BlogPosts_IsPublished_UpdatedDate"); + + b.ToTable("BlogPosts"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("BlogPostId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Clicks") + .HasColumnType("INTEGER"); + + b.Property("DateClicked") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BlogPostRecords"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.BlogPostTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ShortDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BlogPostTemplates"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.ProfileInformationEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ProfileInformationEntries"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.ShortCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("MarkdownContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ShortCodes"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.SimilarBlogPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("SimilarBlogPostIds") + .IsRequired() + .HasMaxLength(1350) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SimilarBlogPosts"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ProficiencyLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Skills"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.Talk", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Place") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PresentationTitle") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Talks"); + }); + + modelBuilder.Entity("LinkDotNet.Blog.Domain.UserRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .IsUnicode(false) + .HasColumnType("TEXT"); + + b.Property("DateClicked") + .HasColumnType("TEXT"); + + b.Property("UrlClicked") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserRecords"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125214130_AddUniqueIndexOnExternalId.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125214130_AddUniqueIndexOnExternalId.cs new file mode 100644 index 00000000..27c081e0 --- /dev/null +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/20260125214130_AddUniqueIndexOnExternalId.cs @@ -0,0 +1,33 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LinkDotNet.Blog.Infrastructure.Migrations; + +/// +public partial class AddUniqueIndexOnExternalId : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.CreateIndex( + name: "IX_BlogPosts_ExternalId", + table: "BlogPosts", + column: "ExternalId", + unique: true, + filter: "ExternalId IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.DropIndex( + name: "IX_BlogPosts_ExternalId", + table: "BlogPosts"); + } +} diff --git a/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs b/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs index f0ee127e..20e09275 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Migrations/BlogDbContextModelSnapshot.cs @@ -32,6 +32,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("ExternalId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + b.Property("IsPublished") .HasColumnType("INTEGER"); @@ -72,6 +76,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("ExternalId") + .IsUnique() + .HasDatabaseName("IX_BlogPosts_ExternalId") + .HasFilter("ExternalId IS NOT NULL"); + b.HasIndex("IsPublished", "UpdatedDate") .IsDescending(false, true) .HasDatabaseName("IX_BlogPosts_IsPublished_UpdatedDate"); diff --git a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs index bdba5a05..2fc0720c 100644 --- a/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs +++ b/src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs @@ -22,6 +22,12 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Tags).HasMaxLength(2048); builder.Property(x => x.AuthorName).HasMaxLength(256).IsRequired(false); + builder.Property(x => x.ExternalId).HasMaxLength(256).IsRequired(false); + + builder.HasIndex(x => x.ExternalId) + .HasDatabaseName("IX_BlogPosts_ExternalId") + .IsUnique() + .HasFilter("ExternalId IS NOT NULL"); builder.HasIndex(x => new { x.IsPublished, x.UpdatedDate }) .HasDatabaseName("IX_BlogPosts_IsPublished_UpdatedDate") diff --git a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs index 6a866d49..6c8e1db9 100644 --- a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs +++ b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs @@ -27,4 +27,6 @@ public sealed record ApplicationConfiguration public bool ShowBuildInformation { get; init; } = true; public bool UseMultiAuthorMode { get; init; } + + public Features.MarkdownImport.MarkdownImportConfiguration? MarkdownImport { get; init; } } diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/Settings/SettingsPage.razor b/src/LinkDotNet.Blog.Web/Features/Admin/Settings/SettingsPage.razor index 88528903..ee9ad226 100644 --- a/src/LinkDotNet.Blog.Web/Features/Admin/Settings/SettingsPage.razor +++ b/src/LinkDotNet.Blog.Web/Features/Admin/Settings/SettingsPage.razor @@ -1,5 +1,6 @@ @page "/settings" @using LinkDotNet.Blog.Web.Features.Services +@using LinkDotNet.Blog.Web.Features.MarkdownImport @using NCronJob @inject IOptions ApplicationConfiguration @inject ICacheInvalidator CacheInvalidator @@ -35,6 +36,16 @@ 10 Minutes + @if (ApplicationConfiguration.Value.MarkdownImport?.Enabled == true) + { + + Run Markdown Import + The markdown import job fetches and imports blog posts from external markdown sources.
+ The job runs every 15 minutes. The job can be run on demand. + 15 Minutes + + + } @@ -51,4 +62,10 @@ InstantJobRegistry.RunInstantJob(); ToastService.ShowInfo("Transformer was started."); } + + private void RunMarkdownImport() + { + InstantJobRegistry.RunInstantJob(); + ToastService.ShowInfo("Markdown import was started."); + } } diff --git a/src/LinkDotNet.Blog.Web/Features/MarkdownImport/FlatDirectoryMarkdownProvider.cs b/src/LinkDotNet.Blog.Web/Features/MarkdownImport/FlatDirectoryMarkdownProvider.cs new file mode 100644 index 00000000..0c7b4604 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/MarkdownImport/FlatDirectoryMarkdownProvider.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace LinkDotNet.Blog.Web.Features.MarkdownImport; + +public sealed partial class FlatDirectoryMarkdownProvider : IMarkdownSourceProvider +{ + private readonly HttpClient httpClient; + private readonly ILogger logger; + + public FlatDirectoryMarkdownProvider( + HttpClient httpClient, + ILogger logger) + { + this.httpClient = httpClient; + this.logger = logger; + } + + public async Task> GetMarkdownFilesAsync(CancellationToken cancellationToken = default) + { + try + { + var baseUri = httpClient.BaseAddress ?? throw new InvalidOperationException("BaseAddress not configured"); + LogFetchingFiles(baseUri.ToString()); + + var directoryContent = await httpClient.GetStringAsync(baseUri, cancellationToken); + var markdownFiles = ExtractMarkdownFileNames(directoryContent); + + LogFoundFiles(markdownFiles.Count); + + var files = new List(); + foreach (var fileName in markdownFiles.OrderBy(f => f)) + { + try + { + var fileUri = new Uri(baseUri, fileName); + var content = await httpClient.GetStringAsync(fileUri, cancellationToken); + files.Add(new MarkdownFile(fileName, content)); + LogFileDownloaded(fileName); + } + catch (Exception ex) + { + LogFileDownloadFailed(fileName, ex); + } + } + + return files; + } + catch (Exception ex) + { + LogFetchFailed(httpClient.BaseAddress?.ToString() ?? "unknown", ex); + return Array.Empty(); + } + } + + private static List ExtractMarkdownFileNames(string htmlContent) + { + var regex = MarkdownLinkRegex(); + var matches = regex.Matches(htmlContent); + return matches + .Select(m => m.Groups[1].Value) + .Where(f => f.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + [GeneratedRegex(@"href=""([^""]+\.md)""", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex MarkdownLinkRegex(); + + [LoggerMessage(Level = LogLevel.Information, Message = "Fetching markdown files from: {BaseUrl}")] + private partial void LogFetchingFiles(string baseUrl); + + [LoggerMessage(Level = LogLevel.Information, Message = "Found {Count} markdown files")] + private partial void LogFoundFiles(int count); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Downloaded file: {FileName}")] + private partial void LogFileDownloaded(string fileName); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to download file '{FileName}'")] + private partial void LogFileDownloadFailed(string fileName, Exception ex); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to fetch markdown files from '{BaseUrl}'")] + private partial void LogFetchFailed(string baseUrl, Exception ex); +} diff --git a/src/LinkDotNet.Blog.Web/Features/MarkdownImport/IMarkdownSourceProvider.cs b/src/LinkDotNet.Blog.Web/Features/MarkdownImport/IMarkdownSourceProvider.cs new file mode 100644 index 00000000..5cf4c5a0 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/MarkdownImport/IMarkdownSourceProvider.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace LinkDotNet.Blog.Web.Features.MarkdownImport; + +public interface IMarkdownSourceProvider +{ + Task> GetMarkdownFilesAsync(CancellationToken cancellationToken = default); +} + +public sealed record MarkdownFile(string FileName, string Content); diff --git a/src/LinkDotNet.Blog.Web/Features/MarkdownImport/MarkdownImportConfiguration.cs b/src/LinkDotNet.Blog.Web/Features/MarkdownImport/MarkdownImportConfiguration.cs new file mode 100644 index 00000000..75b3c905 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/MarkdownImport/MarkdownImportConfiguration.cs @@ -0,0 +1,10 @@ +namespace LinkDotNet.Blog.Web.Features.MarkdownImport; + +public sealed record MarkdownImportConfiguration +{ + public bool Enabled { get; init; } + + public string SourceType { get; init; } = "FlatDirectory"; + + public string Url { get; init; } = string.Empty; +} diff --git a/src/LinkDotNet.Blog.Web/Features/MarkdownImport/MarkdownImportJob.cs b/src/LinkDotNet.Blog.Web/Features/MarkdownImport/MarkdownImportJob.cs new file mode 100644 index 00000000..ada8bb7b --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/MarkdownImport/MarkdownImportJob.cs @@ -0,0 +1,168 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure; +using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Web.Features.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NCronJob; + +namespace LinkDotNet.Blog.Web.Features.MarkdownImport; + +public sealed partial class MarkdownImportJob : IJob +{ + private readonly IRepository blogPostRepository; + private readonly IMarkdownSourceProvider sourceProvider; + private readonly MarkdownImportParser parser; + private readonly ICacheInvalidator cacheInvalidator; + private readonly ILogger logger; + private readonly MarkdownImportConfiguration? configuration; + + public MarkdownImportJob( + IRepository blogPostRepository, + IMarkdownSourceProvider sourceProvider, + MarkdownImportParser parser, + ICacheInvalidator cacheInvalidator, + IOptions appConfiguration, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(appConfiguration); + + this.blogPostRepository = blogPostRepository; + this.sourceProvider = sourceProvider; + this.parser = parser; + this.cacheInvalidator = cacheInvalidator; + this.logger = logger; + this.configuration = appConfiguration.Value.MarkdownImport; + } + + public async Task RunAsync(IJobExecutionContext context, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(context); + + if (configuration is null || !configuration.Enabled) + { + LogJobDisabled(); + return; + } + + LogJobStarting(); + + var importedCount = 0; + var updatedCount = 0; + var errorCount = 0; + + try + { + var markdownFiles = await sourceProvider.GetMarkdownFilesAsync(token); + LogFilesRetrieved(markdownFiles.Count); + + foreach (var file in markdownFiles) + { + try + { + var parsedContent = parser.Parse(file.Content, file.FileName); + if (parsedContent is null) + { + errorCount++; + continue; + } + + var existingPost = await blogPostRepository.GetAllAsync( + filter: bp => bp.ExternalId == parsedContent.Metadata.Id); + + if (existingPost.Count > 0) + { + await UpdateExistingPostAsync(existingPost[0], parsedContent); + updatedCount++; + LogPostUpdated(file.FileName, parsedContent.Metadata.Id); + } + else + { + await CreateNewPostAsync(parsedContent); + importedCount++; + LogPostCreated(file.FileName, parsedContent.Metadata.Id); + } + } + catch (Exception ex) + { + errorCount++; + LogFileProcessingFailed(file.FileName, ex); + } + } + + if (importedCount > 0 || updatedCount > 0) + { + await cacheInvalidator.ClearCacheAsync(); + } + + LogJobCompleted(importedCount, updatedCount, errorCount); + } + catch (Exception ex) + { + LogJobFailed(ex); + } + } + + private async Task UpdateExistingPostAsync(BlogPost existingPost, Domain.MarkdownImport.MarkdownContent content) + { + var updatedPost = BlogPost.Create( + title: content.Metadata.Title, + shortDescription: content.ShortDescription, + content: content.Content, + previewImageUrl: content.Metadata.Image, + isPublished: content.Metadata.Published, + updatedDate: content.Metadata.UpdatedDate, + tags: content.Metadata.Tags, + previewImageUrlFallback: content.Metadata.FallbackImage, + authorName: content.Metadata.AuthorName, + externalId: content.Metadata.Id); + + existingPost.Update(updatedPost); + await blogPostRepository.StoreAsync(existingPost); + } + + private async Task CreateNewPostAsync(Domain.MarkdownImport.MarkdownContent content) + { + var newPost = BlogPost.Create( + title: content.Metadata.Title, + shortDescription: content.ShortDescription, + content: content.Content, + previewImageUrl: content.Metadata.Image, + isPublished: content.Metadata.Published, + updatedDate: content.Metadata.UpdatedDate, + tags: content.Metadata.Tags, + previewImageUrlFallback: content.Metadata.FallbackImage, + authorName: content.Metadata.AuthorName, + externalId: content.Metadata.Id); + + await blogPostRepository.StoreAsync(newPost); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Markdown import job is disabled")] + private partial void LogJobDisabled(); + + [LoggerMessage(Level = LogLevel.Information, Message = "Markdown import job starting")] + private partial void LogJobStarting(); + + [LoggerMessage(Level = LogLevel.Information, Message = "Retrieved {Count} markdown files from source")] + private partial void LogFilesRetrieved(int count); + + [LoggerMessage(Level = LogLevel.Information, Message = "Created new blog post from file '{FileName}' with ExternalId '{ExternalId}'")] + private partial void LogPostCreated(string fileName, string externalId); + + [LoggerMessage(Level = LogLevel.Information, Message = "Updated existing blog post from file '{FileName}' with ExternalId '{ExternalId}'")] + private partial void LogPostUpdated(string fileName, string externalId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to process file '{FileName}'")] + private partial void LogFileProcessingFailed(string fileName, Exception ex); + + [LoggerMessage(Level = LogLevel.Information, Message = "Markdown import job completed: {ImportedCount} created, {UpdatedCount} updated, {ErrorCount} errors")] + private partial void LogJobCompleted(int importedCount, int updatedCount, int errorCount); + + [LoggerMessage(Level = LogLevel.Error, Message = "Markdown import job failed")] + private partial void LogJobFailed(Exception ex); +} diff --git a/src/LinkDotNet.Blog.Web/Features/MarkdownImport/MarkdownImportParser.cs b/src/LinkDotNet.Blog.Web/Features/MarkdownImport/MarkdownImportParser.cs new file mode 100644 index 00000000..15088473 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/MarkdownImport/MarkdownImportParser.cs @@ -0,0 +1,136 @@ +using System; +using System.Globalization; +using System.Linq; +using LinkDotNet.Blog.Domain.MarkdownImport; +using Microsoft.Extensions.Logging; + +namespace LinkDotNet.Blog.Web.Features.MarkdownImport; + +public sealed partial class MarkdownImportParser +{ + private const string Delimiter = "----------"; + private readonly ILogger logger; + + public MarkdownImportParser(ILogger logger) + { + this.logger = logger; + } + + public MarkdownContent? Parse(string markdownText, string fileName) + { + ArgumentNullException.ThrowIfNull(markdownText); + + try + { + var sections = markdownText.Split(new[] { Delimiter }, StringSplitOptions.None); + + if (sections.Length < 4) + { + LogInvalidFormat(fileName, "Expected at least 3 sections separated by delimiter"); + return null; + } + + var headerSection = sections[1].Trim(); + var shortDescriptionSection = sections[2].Trim(); + var contentSection = string.Join(Delimiter, sections.Skip(3)).Trim(); + + var metadata = ParseMetadata(headerSection, fileName); + if (metadata is null) + { + return null; + } + + if (string.IsNullOrWhiteSpace(shortDescriptionSection)) + { + LogInvalidFormat(fileName, "Short description section is empty"); + return null; + } + + if (string.IsNullOrWhiteSpace(contentSection)) + { + LogInvalidFormat(fileName, "Content section is empty"); + return null; + } + + return new MarkdownContent(metadata, shortDescriptionSection, contentSection); + } + catch (Exception ex) + { + LogParseException(fileName, ex); + return null; + } + } + + private MarkdownMetadata? ParseMetadata(string headerSection, string fileName) + { + var lines = headerSection.Split('\n', StringSplitOptions.RemoveEmptyEntries); + var fields = lines + .Select(line => line.Split(':', 2, StringSplitOptions.TrimEntries)) + .Where(parts => parts.Length == 2) + .ToDictionary(parts => parts[0].ToUpperInvariant(), parts => parts[1], StringComparer.OrdinalIgnoreCase); + + if (!fields.TryGetValue("ID", out var id) || string.IsNullOrWhiteSpace(id)) + { + LogMissingRequiredField(fileName, "id"); + return null; + } + + if (!fields.TryGetValue("title", out var title) || string.IsNullOrWhiteSpace(title)) + { + LogMissingRequiredField(fileName, "title"); + return null; + } + + if (!fields.TryGetValue("image", out var image) || string.IsNullOrWhiteSpace(image)) + { + LogMissingRequiredField(fileName, "image"); + return null; + } + + if (!fields.TryGetValue("published", out var publishedStr) || + !bool.TryParse(publishedStr, out var published)) + { + LogMissingRequiredField(fileName, "published"); + return null; + } + + var tags = fields.TryGetValue("tags", out var tagsStr) && !string.IsNullOrWhiteSpace(tagsStr) + ? tagsStr.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + : Array.Empty(); + + var fallbackImage = fields.TryGetValue("fallbackimage", out var fallback) && !string.IsNullOrWhiteSpace(fallback) + ? fallback + : null; + + DateTime? updatedDate = null; + if (fields.TryGetValue("updateddate", out var updatedDateStr) && !string.IsNullOrWhiteSpace(updatedDateStr)) + { + if (DateTime.TryParse(updatedDateStr, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsedDate)) + { + updatedDate = parsedDate; + } + else + { + LogInvalidField(fileName, "updatedDate", updatedDateStr); + } + } + + var authorName = fields.TryGetValue("AUTHORNAME", out var author) && !string.IsNullOrWhiteSpace(author) + ? author + : null; + + return new MarkdownMetadata(id, title, image, published, tags, fallbackImage, updatedDate, authorName); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to parse markdown file '{FileName}': {Reason}")] + private partial void LogInvalidFormat(string fileName, string reason); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Missing required field '{FieldName}' in file '{FileName}'")] + private partial void LogMissingRequiredField(string fileName, string fieldName); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Invalid value for field '{FieldName}' in file '{FileName}': {Value}")] + private partial void LogInvalidField(string fileName, string fieldName, string value); + + [LoggerMessage(Level = LogLevel.Error, Message = "Exception parsing markdown file '{FileName}'")] + private partial void LogParseException(string fileName, Exception ex); +} diff --git a/src/LinkDotNet.Blog.Web/RegistrationExtensions/BackgroundServiceRegistrationExtensions.cs b/src/LinkDotNet.Blog.Web/RegistrationExtensions/BackgroundServiceRegistrationExtensions.cs index 1e825876..d4ad76f5 100644 --- a/src/LinkDotNet.Blog.Web/RegistrationExtensions/BackgroundServiceRegistrationExtensions.cs +++ b/src/LinkDotNet.Blog.Web/RegistrationExtensions/BackgroundServiceRegistrationExtensions.cs @@ -1,4 +1,5 @@ using LinkDotNet.Blog.Web.Features; +using LinkDotNet.Blog.Web.Features.MarkdownImport; using NCronJob; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -27,6 +28,10 @@ public static void AddBackgroundServices(this IServiceCollection services) options.AddJob(c => c .WithName(nameof(SimilarBlogPostJob)) .OnlyIf((IOptions applicationConfiguration) => applicationConfiguration.Value.ShowSimilarPosts)); + + options.AddJob(p => p.WithCronExpression("*/15 * * * *") + .OnlyIf((IOptions applicationConfiguration) => + applicationConfiguration.Value.MarkdownImport?.Enabled ?? false)); }); } } diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index 9ed92a1b..a531199a 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -1,10 +1,12 @@ using System; +using System.Net.Http; using System.Threading.RateLimiting; using Blazorise; using Blazorise.Bootstrap5; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services; using LinkDotNet.Blog.Web.Features.Bookmarks; +using LinkDotNet.Blog.Web.Features.MarkdownImport; using LinkDotNet.Blog.Web.Features.Services; using LinkDotNet.Blog.Web.RegistrationExtensions; using Microsoft.AspNetCore.Builder; @@ -30,6 +32,17 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); + services.AddScoped(); + services.AddHttpClient((sp, client) => + { + var config = sp.GetRequiredService>().Value; + if (!string.IsNullOrEmpty(config.MarkdownImport?.Url)) + { + client.BaseAddress = new Uri(config.MarkdownImport.Url.TrimEnd('/')); + } + }); + services.AddScoped(); + services.AddBackgroundServices(); return services; diff --git a/src/LinkDotNet.Blog.Web/appsettings.json b/src/LinkDotNet.Blog.Web/appsettings.json index bbddcc7d..89fdbc86 100644 --- a/src/LinkDotNet.Blog.Web/appsettings.json +++ b/src/LinkDotNet.Blog.Web/appsettings.json @@ -1,5 +1,5 @@ { - "ConfigVersion": "12.0", + "ConfigVersion": "13.0", "Logging": { "LogLevel": { "Default": "Information", @@ -49,5 +49,10 @@ "ShowReadingIndicator": true, "ShowSimilarPosts": true, "ShowBuildInformation": true, - "UseMultiAuthorMode": false + "UseMultiAuthorMode": false, + "MarkdownImport": { + "Enabled": false, + "SourceType": "FlatDirectory", + "Url": "" + } } diff --git a/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/MarkdownImportJobTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/MarkdownImportJobTests.cs new file mode 100644 index 00000000..36942ff5 --- /dev/null +++ b/tests/LinkDotNet.Blog.IntegrationTests/Web/Features/MarkdownImportJobTests.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Domain.MarkdownImport; +using LinkDotNet.Blog.Infrastructure; +using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web; +using LinkDotNet.Blog.Web.Features.MarkdownImport; +using LinkDotNet.Blog.Web.Features.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NCronJob; +using TestContext = Xunit.TestContext; + +namespace LinkDotNet.Blog.IntegrationTests.Web.Features; + +public class MarkdownImportJobTests : SqlDatabaseTestBase +{ + private readonly IMarkdownSourceProvider mockSourceProvider; + private readonly MarkdownImportParser parser; + private readonly ICacheInvalidator mockCacheInvalidator; + private readonly IOptions appConfiguration; + + public MarkdownImportJobTests() + { + mockSourceProvider = Substitute.For(); + parser = new MarkdownImportParser(Substitute.For>()); + mockCacheInvalidator = Substitute.For(); + appConfiguration = Options.Create(new ApplicationConfiguration + { + BlogName = "Test Blog", + ConnectionString = "Data Source=:memory:", + DatabaseName = "TestDb", + MarkdownImport = new MarkdownImportConfiguration + { + Enabled = true, + SourceType = "FlatDirectory", + Url = "https://example.com" + } + }); + } + + [Fact] + public async Task Should_Create_New_Post_From_Markdown() + { + // Arrange + var markdownContent = """ + ---------- + id: test-post-1 + title: Test Blog Post + image: https://example.com/image.jpg + published: true + tags: csharp, testing + ---------- + This is a short description + ---------- + # Content + This is the main content of the blog post. + """; + + var markdownFiles = new List + { + new("test-post.md", markdownContent) + }; + + mockSourceProvider.GetMarkdownFilesAsync(Arg.Any()) + .Returns(markdownFiles); + + var sut = new MarkdownImportJob( + Repository, + mockSourceProvider, + parser, + mockCacheInvalidator, + appConfiguration, + Substitute.For>()); + + // Act + await sut.RunAsync(Substitute.For(), TestContext.Current.CancellationToken); + + // Assert + var posts = await Repository.GetAllAsync(); + posts.Count.ShouldBe(1); + + var post = posts[0]; + post.Title.ShouldBe("Test Blog Post"); + post.ShortDescription.ShouldBe("This is a short description"); + post.Content.ShouldContain("# Content"); + post.PreviewImageUrl.ShouldBe("https://example.com/image.jpg"); + post.IsPublished.ShouldBeTrue(); + post.Tags.ShouldContain("csharp"); + post.Tags.ShouldContain("testing"); + post.ExternalId.ShouldBe("test-post-1"); + + await mockCacheInvalidator.Received(1).ClearCacheAsync(); + } + + [Fact] + public async Task Should_Update_Existing_Post_By_ExternalId() + { + // Arrange + var existingPost = BlogPost.Create( + title: "Old Title", + shortDescription: "Old description", + content: "Old content", + previewImageUrl: "https://example.com/old.jpg", + isPublished: false, + externalId: "test-post-1"); + + await Repository.StoreAsync(existingPost); + + var markdownContent = """ + ---------- + id: test-post-1 + title: Updated Title + image: https://example.com/new.jpg + published: true + tags: csharp, updated + ---------- + Updated short description + ---------- + # Updated Content + This is the updated content. + """; + + var markdownFiles = new List + { + new("test-post.md", markdownContent) + }; + + mockSourceProvider.GetMarkdownFilesAsync(Arg.Any()) + .Returns(markdownFiles); + + var sut = new MarkdownImportJob( + Repository, + mockSourceProvider, + parser, + mockCacheInvalidator, + appConfiguration, + Substitute.For>()); + + // Act + await sut.RunAsync(Substitute.For(), TestContext.Current.CancellationToken); + + // Assert + var posts = await Repository.GetAllAsync(); + posts.Count.ShouldBe(1); + + var post = posts[0]; + post.Id.ShouldBe(existingPost.Id); // Should be the same post + post.Title.ShouldBe("Updated Title"); + post.ShortDescription.ShouldBe("Updated short description"); + post.Content.ShouldContain("# Updated Content"); + post.PreviewImageUrl.ShouldBe("https://example.com/new.jpg"); + post.IsPublished.ShouldBeTrue(); + post.ExternalId.ShouldBe("test-post-1"); + + await mockCacheInvalidator.Received(1).ClearCacheAsync(); + } + + [Fact] + public async Task Should_Not_Run_When_Feature_Is_Disabled() + { + // Arrange + var disabledAppConfiguration = Options.Create(new ApplicationConfiguration + { + BlogName = "Test Blog", + ConnectionString = "Data Source=:memory:", + DatabaseName = "TestDb", + MarkdownImport = new MarkdownImportConfiguration + { + Enabled = false + } + }); + + var sut = new MarkdownImportJob( + Repository, + mockSourceProvider, + parser, + mockCacheInvalidator, + disabledAppConfiguration, + Substitute.For>()); + + // Act + await sut.RunAsync(Substitute.For(), TestContext.Current.CancellationToken); + + // Assert + var posts = await Repository.GetAllAsync(); + posts.Count.ShouldBe(0); + + await mockSourceProvider.DidNotReceive().GetMarkdownFilesAsync(Arg.Any()); + await mockCacheInvalidator.DidNotReceive().ClearCacheAsync(); + } + + [Fact] + public async Task Should_Not_Run_When_Configuration_Is_Null() + { + // Arrange + var nullAppConfiguration = Options.Create(new ApplicationConfiguration + { + BlogName = "Test Blog", + ConnectionString = "Data Source=:memory:", + DatabaseName = "TestDb", + MarkdownImport = null + }); + + var sut = new MarkdownImportJob( + Repository, + mockSourceProvider, + parser, + mockCacheInvalidator, + nullAppConfiguration, + Substitute.For>()); + + // Act + await sut.RunAsync(Substitute.For(), TestContext.Current.CancellationToken); + + // Assert + var posts = await Repository.GetAllAsync(); + posts.Count.ShouldBe(0); + + await mockSourceProvider.DidNotReceive().GetMarkdownFilesAsync(Arg.Any()); + await mockCacheInvalidator.DidNotReceive().ClearCacheAsync(); + } + + [Fact] + public async Task Should_Handle_Invalid_Markdown_Gracefully() + { + // Arrange + var invalidMarkdownContent = """ + This is not valid markdown format + Missing delimiters + """; + + var markdownFiles = new List + { + new("invalid.md", invalidMarkdownContent) + }; + + mockSourceProvider.GetMarkdownFilesAsync(Arg.Any()) + .Returns(markdownFiles); + + var sut = new MarkdownImportJob( + Repository, + mockSourceProvider, + parser, + mockCacheInvalidator, + appConfiguration, + Substitute.For>()); + + // Act + await sut.RunAsync(Substitute.For(), TestContext.Current.CancellationToken); + + // Assert + var posts = await Repository.GetAllAsync(); + posts.Count.ShouldBe(0); + + // Cache should not be cleared when no posts are created or updated + await mockCacheInvalidator.DidNotReceive().ClearCacheAsync(); + } + + [Fact] + public async Task Should_Handle_Source_Provider_Exception() + { + // Arrange + mockSourceProvider.GetMarkdownFilesAsync(Arg.Any()) + .Returns>(_ => throw new Exception("Network error")); + + var sut = new MarkdownImportJob( + Repository, + mockSourceProvider, + parser, + mockCacheInvalidator, + appConfiguration, + Substitute.For>()); + + // Act + await sut.RunAsync(Substitute.For(), TestContext.Current.CancellationToken); + + // Assert + var posts = await Repository.GetAllAsync(); + posts.Count.ShouldBe(0); + + await mockCacheInvalidator.DidNotReceive().ClearCacheAsync(); + } + + [Fact] + public async Task Should_Handle_Parsing_Exception_For_Individual_File() + { + // Arrange + var validMarkdownContent = """ + ---------- + id: valid-post + title: Valid Post + image: https://example.com/image.jpg + published: true + ---------- + Short description + ---------- + Content + """; + + var invalidMarkdownContent = """ + ---------- + id: invalid-post + title: Missing required fields + ---------- + Short description + ---------- + Content + """; + + var markdownFiles = new List + { + new("valid.md", validMarkdownContent), + new("invalid.md", invalidMarkdownContent) + }; + + mockSourceProvider.GetMarkdownFilesAsync(Arg.Any()) + .Returns(markdownFiles); + + var sut = new MarkdownImportJob( + Repository, + mockSourceProvider, + parser, + mockCacheInvalidator, + appConfiguration, + Substitute.For>()); + + // Act + await sut.RunAsync(Substitute.For(), TestContext.Current.CancellationToken); + + // Assert + var posts = await Repository.GetAllAsync(); + posts.Count.ShouldBe(1); // Only valid post should be created + posts[0].ExternalId.ShouldBe("valid-post"); + + // Cache should still be cleared because one post was created + await mockCacheInvalidator.Received(1).ClearCacheAsync(); + } + + [Fact] + public async Task Should_Import_Multiple_Posts() + { + // Arrange + var markdown1 = """ + ---------- + id: post-1 + title: Post 1 + image: https://example.com/1.jpg + published: true + ---------- + Description 1 + ---------- + Content 1 + """; + + var markdown2 = """ + ---------- + id: post-2 + title: Post 2 + image: https://example.com/2.jpg + published: false + ---------- + Description 2 + ---------- + Content 2 + """; + + var markdownFiles = new List + { + new("post1.md", markdown1), + new("post2.md", markdown2) + }; + + mockSourceProvider.GetMarkdownFilesAsync(Arg.Any()) + .Returns(markdownFiles); + + var sut = new MarkdownImportJob( + Repository, + mockSourceProvider, + parser, + mockCacheInvalidator, + appConfiguration, + Substitute.For>()); + + // Act + await sut.RunAsync(Substitute.For(), TestContext.Current.CancellationToken); + + // Assert + var posts = await Repository.GetAllAsync(); + posts.Count.ShouldBe(2); + + posts.ShouldContain(p => p.ExternalId == "post-1"); + posts.ShouldContain(p => p.ExternalId == "post-2"); + + await mockCacheInvalidator.Received(1).ClearCacheAsync(); + } + + [Fact] + public async Task Should_Not_Clear_Cache_When_No_Changes() + { + // Arrange + mockSourceProvider.GetMarkdownFilesAsync(Arg.Any()) + .Returns(new List()); + + var sut = new MarkdownImportJob( + Repository, + mockSourceProvider, + parser, + mockCacheInvalidator, + appConfiguration, + Substitute.For>()); + + // Act + await sut.RunAsync(Substitute.For(), TestContext.Current.CancellationToken); + + // Assert + await mockCacheInvalidator.DidNotReceive().ClearCacheAsync(); + } +} diff --git a/tests/LinkDotNet.Blog.TestUtilities/ApplicationConfigurationBuilder.cs b/tests/LinkDotNet.Blog.TestUtilities/ApplicationConfigurationBuilder.cs index 1f5dd2e6..ee0f208e 100644 --- a/tests/LinkDotNet.Blog.TestUtilities/ApplicationConfigurationBuilder.cs +++ b/tests/LinkDotNet.Blog.TestUtilities/ApplicationConfigurationBuilder.cs @@ -1,4 +1,5 @@ using LinkDotNet.Blog.Web; +using LinkDotNet.Blog.Web.Features.MarkdownImport; namespace LinkDotNet.Blog.TestUtilities; @@ -17,6 +18,7 @@ public class ApplicationConfigurationBuilder private bool showBuildInformation = true; private string? blogBrandUrl; private bool useMultiAuthorMode; + private MarkdownImportConfiguration? markdownImport; public ApplicationConfigurationBuilder WithBlogName(string blogName) { @@ -96,6 +98,17 @@ public ApplicationConfigurationBuilder WithUseMultiAuthorMode(bool useMultiAutho return this; } + public ApplicationConfigurationBuilder WithMarkdownImport(bool enabled, string sourceType, string url) + { + this.markdownImport = new MarkdownImportConfiguration + { + Enabled = enabled, + SourceType = sourceType, + Url = url + }; + return this; + } + public ApplicationConfiguration Build() { return new ApplicationConfiguration @@ -113,6 +126,7 @@ public ApplicationConfiguration Build() ShowBuildInformation = showBuildInformation, BlogBrandUrl = blogBrandUrl, UseMultiAuthorMode = useMultiAuthorMode, + MarkdownImport = markdownImport, }; } } diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/Settings/SettingsPageTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/Settings/SettingsPageTests.cs index 8678b13a..09363a69 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/Settings/SettingsPageTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Admin/Settings/SettingsPageTests.cs @@ -1,7 +1,9 @@ using Blazored.Toast.Services; using LinkDotNet.Blog.TestUtilities; using LinkDotNet.Blog.Web; +using LinkDotNet.Blog.Web.Features; using LinkDotNet.Blog.Web.Features.Admin.Settings; +using LinkDotNet.Blog.Web.Features.MarkdownImport; using LinkDotNet.Blog.Web.Features.Services; using NCronJob; using Microsoft.Extensions.DependencyInjection; @@ -26,4 +28,60 @@ public void GivenSettingsPage_WhenClicking_InvalidateCacheButton_CacheIsCleared( cacheInvalidator.Received(1).ClearCacheAsync(); } + + [Fact] + public void GivenSettingsPage_WhenClicking_RunTransformerButton_JobIsTriggered() + { + var instantJobRegistry = Substitute.For(); + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => Options.Create(new ApplicationConfigurationBuilder().Build())); + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => instantJobRegistry); + var cut = Render(); + var runTransformerButton = cut.Find("#run-visit-transformer"); + + runTransformerButton.Click(); + +#pragma warning disable xUnit1051 // Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken + instantJobRegistry.Received(1).RunInstantJob(); +#pragma warning restore xUnit1051 + } + + [Fact] + public void GivenMarkdownImportEnabled_WhenClicking_RunMarkdownImportButton_JobIsTriggered() + { + var instantJobRegistry = Substitute.For(); + var config = new ApplicationConfigurationBuilder() + .WithMarkdownImport(true, "FlatDirectory", "https://example.com/markdown/") + .Build(); + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => Options.Create(config)); + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => instantJobRegistry); + var cut = Render(); + var runImportButton = cut.Find("#run-markdown-import"); + + runImportButton.Click(); + +#pragma warning disable xUnit1051 // Calls to methods which accept CancellationToken should use TestContext.Current.CancellationToken + instantJobRegistry.Received(1).RunInstantJob(); +#pragma warning restore xUnit1051 + } + + [Fact] + public void GivenMarkdownImportDisabled_MarkdownImportButtonNotShown() + { + var config = new ApplicationConfigurationBuilder() + .WithMarkdownImport(false, "FlatDirectory", "") + .Build(); + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => Options.Create(config)); + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => Substitute.For()); + var cut = Render(); + + var buttons = cut.FindAll("#run-markdown-import"); + + buttons.ShouldBeEmpty(); + } } diff --git a/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs b/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs index ea483f18..de3bd8ad 100644 --- a/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs +++ b/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs @@ -13,7 +13,7 @@ public MigrationManagerTests() } [Fact] - public async Task Should_Migrate_From_11_To_12() + public async Task Should_Migrate_To_Version_13() { // Arrange var testFile = Path.Combine(testDirectory, "appsettings.Development.json"); @@ -32,8 +32,9 @@ public async Task Should_Migrate_From_11_To_12() // Assert result.ShouldBeTrue(); var content = await File.ReadAllTextAsync(testFile, TestContext.Current.CancellationToken); - content.ShouldContain("\"ConfigVersion\": \"12.0\""); + content.ShouldContain("\"ConfigVersion\": \"13.0\""); content.ShouldContain("\"ShowBuildInformation\": true"); + content.ShouldContain("\"MarkdownImport\""); // Verify backup was created var backupFiles = Directory.GetFiles(backupDir); @@ -47,9 +48,14 @@ public async Task Should_Not_Modify_Already_Migrated_File() var testFile = Path.Combine(testDirectory, "appsettings.Production.json"); var json = """ { - "ConfigVersion": "12.0", + "ConfigVersion": "13.0", "BlogName": "Test Blog", - "ShowBuildInformation": true + "ShowBuildInformation": true, + "MarkdownImport": { + "Enabled": false, + "SourceType": "FlatDirectory", + "Url": "" + } } """; await File.WriteAllTextAsync(testFile, json, TestContext.Current.CancellationToken); diff --git a/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationManager.cs b/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationManager.cs index ddc075f4..46e03f7c 100644 --- a/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationManager.cs +++ b/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationManager.cs @@ -14,7 +14,8 @@ public MigrationManager() { _migrations = new List { - new Migration11To12() + new Migration11To12(), + new Migration12To13() }; _currentVersion = DetermineCurrentVersionFromMigrations(); diff --git a/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_12_To_13.cs b/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_12_To_13.cs new file mode 100644 index 00000000..6b3c6bfa --- /dev/null +++ b/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_12_To_13.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace LinkDotNet.Blog.UpgradeAssistant.Migrations; + +/// +/// Migration from version 12.0 to 13.0. +/// Adds MarkdownImport configuration section. +/// +public sealed class Migration12To13 : IMigration +{ + public string FromVersion => "12.0"; + public string ToVersion => "13.0"; + + public bool Apply(JsonDocument document, ref string jsonContent) + { + var jsonNode = JsonNode.Parse(jsonContent); + if (jsonNode is not JsonObject rootObject) + { + return false; + } + + var hasChanges = false; + + if (!rootObject.ContainsKey("MarkdownImport")) + { + var markdownImportConfig = new JsonObject + { + ["Enabled"] = false, + ["SourceType"] = "FlatDirectory", + ["Url"] = string.Empty + }; + + rootObject["MarkdownImport"] = markdownImportConfig; + hasChanges = true; + ConsoleOutput.WriteInfo("Added 'MarkdownImport' configuration section."); + ConsoleOutput.WriteInfo(" - Enabled: false (set to true to enable markdown import)"); + ConsoleOutput.WriteInfo(" - SourceType: FlatDirectory"); + ConsoleOutput.WriteInfo(" - Url: (configure the source URL for markdown files)"); + } + + if (hasChanges) + { + var options = new JsonSerializerOptions { WriteIndented = true }; + jsonContent = jsonNode.ToJsonString(options); + } + + return hasChanges; + } + + public string GetDescription() + { + return "Adds MarkdownImport configuration section for external markdown file import feature."; + } +}