<|start_filename|>src/Views/Blog/Edit.cshtml<|end_filename|>
﻿@model Post
@inject IOptionsSnapshot<BlogSettings> settings
@{
    bool isNew = string.IsNullOrEmpty(Model.Title);
    ViewData["Title"] = "Edit " + (Model.Title ?? "new post");
    var host = Context.Request.Host.ToString();
    List<string> allCats = ViewData["AllCats"] as List<string> ?? new List<string>();
    allCats.Sort();
}

@section Head{
    <link rel="stylesheet" href="~/css/admin.scss" />
    <link href="~/css/github-markdown.min.css" rel="stylesheet" />
    @if (settings.Value.MarkDown)
    {
        <link href="~/css/simplemde.min.css" rel="stylesheet" />
    }
}

        <form method="post" id="edit" class="container" asp-controller="Blog" asp-action="UpdatePost">
            <div asp-validation-summary="All"></div>
            <input type="hidden" asp-for="@Model.ID" />

            <label asp-for="@Model.Title" class="label">Title</label>
            <input asp-for="@Model.Title" required placeholder="Title of blog post" aria-describedby="desc_title" />
            <span class="desc" id="desc_title">The title as it appears on the website</span>
            <br />

            <label asp-for="@Model.Slug" class="label">Slug</label>
            <input asp-for="@Model.Slug" placeholder="The URL name" aria-describedby="desc_slug" />
            <span class="desc" id="desc_slug">The part of the URL that identifies this blog post</span>
            <br />

            <label for="categories" class="label">Tags</label>
            <input type="text" name="selecttag" id="selecttag" aria-describedby="desc_categories" list="taglist" placeholder="@string.Join(", ", Model.Categories)" />
            <input type="text" name="categories" id="categories" value="@string.Join(", ", Model.Categories)" hidden />
            <span class="desc" id="desc_categories">Select, or build a comma separated list of keywords - to remove double the keyword</span>
            <datalist id="taglist">
                @foreach (var cat in allCats)
                {
                    <option value="@cat" />
                }
            </datalist>
            <br />

            <label asp-for="@Model.Excerpt" class="label">Excerpt</label>
            <textarea asp-for="@Model.Excerpt" rows="3" placeholder="Short description of blog post" aria-describedby="desc_excerpt">@Model.Excerpt</textarea>
            <span class="desc" id="desc_excerpt">A brief description of the content of the post</span>
            <br />





            <hr />

            @if (settings.Value.MarkDown)
            {
                @* MarkDown功能 *@
                <section class="input">
                    <h3 class="main">輸入MarkDown:</h3>
                    <article class="markdown-body">
                        <textarea id="markdwonTextArea" asp-for="@Model.MarkDownContent"></textarea>
                        <textarea hidden id="markdwonTextAreaHiddenText" asp-for="@Model.Content"></textarea>
                    </article>
                </section>


                <hr />
            }
            else
            {
                <textarea asp-for="@Model.Content" rows="20" aria-label="Content"></textarea>
            }

            <input type="submit" value="Save" title="Save the post" />
            <input asp-for="@Model.IsPublished" />
            <label asp-for="@Model.IsPublished">Is published</label>
            <input asp-for="@Model.IsMarkDown" checked readonly disabled />
            <label asp-for="@Model.IsMarkDown">Is IsMarkDown</label>
            @if (!isNew)
            {
                <input type="submit" value="Delete..." class="delete" title="Delete the post..." asp-action="DeletePost" asp-route-id="@Model.ID" />
            }
            <input hidden id="toLowerCase" value="@MiniBlogStringExtensions.GetToLowerInvariant()" /> @* 控制標籤是否要大小寫 *@
        </form>

@section Scripts {
    <script src="//@host/lib/tinymce/tinymce.min.js"></script> @*with enabled CDN tinymce is not able to find some files*@
    @if (settings.Value.MarkDown)
    {
        <script src="~/js/admin.js" async></script>
        <script src="~/lib/simplemde.min.js"></script>
        <script>
            //簡單MarkDownEditor
            var hiddenMarkDownTextArea = document.getElementById("markdwonTextAreaHiddenText");
            function reloadMarkDownData() {
                var plain = simplemde.value();
                var markdown = simplemde.markdown(plain);
                hiddenMarkDownTextArea.value = markdown;
                //console.log(testMarkdown);
            }
            var simplemde = new SimpleMDE({
                element: document.getElementById("markdwonTextArea"),
                status: ["autosave", {
                    className: "CodeMirror-code",
                    onUpdate: function (el) {
                        reloadMarkDownData();
                    }
                }]
            });
        </script>
    }
    else
    {
        <script src="~/js/admin.js" async></script>
    }
}

<|start_filename|>src/Views/Shared/_Layout.cshtml<|end_filename|>
﻿@inject IOptionsSnapshot<BlogSettings> settings
@inject WebManifest manifest
@{
    var host = Context.Request.Scheme + "://" + Context.Request.Host;

    List<string> allCats = ViewData["AllCats"] as List<string> ?? new List<string>();
    allCats.Sort();
    var selectcategory = ViewData["selectcategory"] as string;
}
<!DOCTYPE html>
<html lang="zh-tw">
<head prefix="og: http://ogp.me/ns#">
    <meta charset="utf-8" />
    <title>@ViewData["Title"]</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="@ViewData["Description"]" />

    <link rel="preload" href="~/js/site.js" as="script" />
    <link rel="preload" href="~/css/belowthefold.scss" as="style" />
    <noscript><link rel="stylesheet" href="~/css/belowthefold.scss" /></noscript>
    @*<link rel="preload" as="style" href="~/css/w3.css" />
        <noscript><link rel="stylesheet" href="~/css/w3.css" /></noscript>*@
    <link rel="stylesheet" href="~/css/w3.css" />
    @RenderSection("Preload", required: false)
    @*<link rel="stylesheet" href="~/css/w3.css" inline />*@
    <link rel="stylesheet" href="~/css/abovethefold.scss" inline />


    <meta property="og:type" content="article" />
    <meta property="og:title" content="@ViewData["Title"]" />
    <meta property="og:description" content="@ViewData["Description"]" />
    <meta property="og:url" content="@(host + Context.Request.Path)" />
    <meta property="og:image" content="@host/img/icon512x512.png" />
    <meta property="og:image:width" content="512" />
    <meta property="og:image:height" content="512" />

    <meta name="msapplication-square150x150logo" content="~/img/icon180x180.png" />

    <link rel="icon" type="image/png" sizes="16x16" href="~/img/icon16x16.png" />
    <link rel="apple-touch-icon" href="~/img/icon180x180.png" />
    <link rel="shortcut icon" href="~/img/icon16x16.png" />

    <link rel="edituri" type="application/rsd+xml" title="RSD" href="~/rsd.xml" />
    <link rel="alternate" type="application/rss+xml" href="~/feed/rss/" />
    <link rel="alternate" type="application/atom+xml" href="~/feed/atom/" />





    @RenderSection("Head", required: false)
</head>
<body itemscope itemtype="http://schema.org/WebPage">
    <!-- Top -->
    <div class="w3-top">
        <div class="w3-row w3-white w3-padding">
            <div class="w3-half" style="margin:4px 0 6px 0">
                <a href='/'>
                    <img src="~/img/logo.gif" alt='logo.gif' style=" width: 280px; height: 32px; " />
                </a>
            </div>
            <div class="w3-half w3-margin-top w3-wide w3-hide-medium w3-hide-small">
                <div class="w3-right" style="position:relative;top:5px;">
                    @* 標頭內容 *@
                </div>
            </div>
        </div>
        @* 書籍 *@
        <div class="w3-bar w3-theme w3-large" style="z-index:4;">
            <a class="w3-bar-item w3-button w3-left w3-hide-large w3-hover-white w3-large w3-theme w3-padding-16 " href="javascript:void(0)"
               onclick="w3_open()">☰</a>
            <a class="w3-bar-item w3-button w3-hide-medium w3-hide-small w3-hover-white w3-padding-16
               @(null == selectcategory ? "active" : "") "
               href="/">首頁</a>
            @foreach (var item in allCats)
            {
                <a class="w3-bar-item w3-button w3-hide-medium w3-hide-small w3-hover-white w3-padding-16
                   @(item == selectcategory ? "active" : "")"
                   asp-controller="Blog" asp-action="category"
                   asp-route-category="@item" asp-route-page="">@item</a>
            }
        </div>
    </div>
    <!-- Sidebar -->
    <div class="w3-sidebar w3-bar-block w3-collapse w3-animate-left" style="z-index:3;width:270px" id="mySidebar">
        @* 手機版書籍頁面 *@
        <div class="w3-bar w3-hide-large w3-large">
            <a class="w3-bar-item w3-button w3-theme w3-hover-white w3-padding-16 @(null == selectcategory ? "active" : "")  "
               href="/">首頁</a>
            @foreach (var item in allCats)
            {
                <a class="w3-bar-item w3-button w3-theme w3-hover-white w3-padding-16 @(item == selectcategory ? "active" : "")"
                   asp-controller="Blog" asp-action="category"
                   asp-route-category="@item" asp-route-page="">@item</a>
            }
        </div>
        <a href="javascript:void(0)" onclick="w3_close()" class="w3-button w3-right w3-xlarge w3-hide-large" title="Close Menu">×</a>
        @* 目錄清單 *@
        <div id="menuTut" class="myMenu">
            <br>
            @RenderSection("Menu", required: false)
            @{ await Html.RenderPartialAsync("CustomSideMenu"); }
        </div>
    </div>

    <!-- Overlay effect when opening sidebar on small screens -->
    <div class="w3-overlay w3-hide-large" onclick="w3_close()" style="cursor:pointer" title="close side menu" id="myOverlay"></div>

    <!-- Main content: shift it to the right by 270 pixels when the sidebar is visible -->
    <main itemprop="mainEntity" itemscope itemtype="http://schema.org/Blog">
        <noscript>
            <p class="co3ntainer">Some features of this website do not work as expected when JavaScript is disabled</p>
        </noscript>
        <!-- Main content: shift it to the right by 270 pixels when the sidebar is visible -->
        <div class="w3-main w3-container" style="margin-left:270px;margin-top:117px;">
            <main itemprop="mainEntity" itemscope itemtype="http://schema.org/Blog">

                @RenderBody()
                <footer class="w3-panel w3-padding-32 w3-card-4 w3-light-grey w3-center w3-opacity">
                    @{
                        string path = Context.Request.Path;

                        if (User.Identity.IsAuthenticated)
                        {
                            @:【
                            <a asp-controller="Blog" asp-action="Edit">新建文章</a>
                            @:/
                            <a href="~/logout/" title="Sign out as administrator">登出</a>
                            @:】
                        }
                        else
                        {
                            if (settings.Value.ShowLogin)
                            {
                                <a href="~/login/?returnUrl=@path" rel="nofollow" title="Sign in as administrator">【登入】</a>
                            }
                        }
                    }
                    <p>
                        Powered by:
                        <a href="https://github.com/shps951023/W3CMiniblog.Core-W3C">W3CMiniblog.Core.W3C</a>
                    </p>
                    <p>© @DateTime.Now.Year</p>
                </footer>
            </main>
            <!-- END MAIN -->
        </div>
    </main>




    <!-- This lazy loads the stylesheet in a non-render blocking way -->
    <link rel="preload" href="~/css/belowthefold.scss" as="style" />
    <noscript><link rel="stylesheet" href="~/css/belowthefold.scss" /></noscript>
    <script>
        // Script to open and close the sidebar
        function w3_open() {
            document.getElementById("mySidebar").style.display = "block";
            document.getElementById("myOverlay").style.display = "block";
        }

        function w3_close() {
            document.getElementById("mySidebar").style.display = "none";
            document.getElementById("myOverlay").style.display = "none";
        }
        function w3_show_nav(name) {
            document.getElementById("menuTut").style.display = "none";
            document.getElementById("menuRef").style.display = "none";
            document.getElementById(name).style.display = "block";
        }
    </script>
    <script src="~/js/site.js" async defer></script>
    @RenderSection("Scripts", required: false)
</body>
</html>


<|start_filename|>src/Services/FileBlogService.cs<|end_filename|>
﻿using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Miniblog.Core.Models;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;


namespace Miniblog.Core.Services
{
    public class FileBlogService : InMemoryBlogServiceBase
    {
        public FileBlogService(IHostingEnvironment env, IHttpContextAccessor contextAccessor)
        {
            _folder = Path.Combine(env.WebRootPath, POSTS);
            _contextAccessor = contextAccessor;

            Initialize();
        }

        #region 改寫部分(override)
        public override async Task SavePost(Post post)
        {
            string filePath = GetFilePath(post);
            post.LastModified = DateTime.UtcNow;

            //注意，需要刪除不符合XML的符號
            XDocument doc = new XDocument(
                            new XElement("post",
                                new XElement("title", ReplaceHexadecimalSymbols(post.Title)),
                                new XElement("slug", post.Slug),
                                new XElement("pubDate", FormatDateTime(post.PubDate)),
                                new XElement("lastModified", FormatDateTime(post.LastModified)),
                                new XElement("excerpt", ReplaceHexadecimalSymbols(post.Excerpt)),
                                new XElement("content", ReplaceHexadecimalSymbols(post.Content)),
                                new XElement("markDownContent", ReplaceHexadecimalSymbols(post.MarkDownContent)),
                                new XElement("isMarkDown", post.IsMarkDown),
                                new XElement("ispublished", post.IsPublished),
                                new XElement("categories", string.Empty),
                                new XElement("comments", string.Empty)
                            ));

            XElement categories = doc.XPathSelectElement("post/categories");
            foreach (string category in post.Categories)
            {
                categories.Add(new XElement("category", category));
            }

            XElement comments = doc.XPathSelectElement("post/comments");
            foreach (Comment comment in post.Comments)
            {
                comments.Add(
                    new XElement("comment",
                        new XElement("author", comment.Author),
                        new XElement("email", comment.Email),
                        new XElement("date", FormatDateTime(comment.PubDate)),
                        new XElement("content", comment.Content),
                        new XAttribute("isAdmin", comment.IsAdmin),
                        new XAttribute("id", comment.ID)
                    ));
            }

            using (var fs = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite))
            {
                await doc.SaveAsync(fs, SaveOptions.None, CancellationToken.None).ConfigureAwait(false);
            }

            /* 如果有資料做更新，沒資料才做新增
             * 補充:原本寫法: !_cache.Contains(post)，會造成假如是創立新的物件
             * ，但ID值是一樣的，可以重複新增到Cache
             */
            if (!_cache.Any(w=>w.ID == post.ID))
            {
                _cache.Add(post);
                SortCache();
            }
            ReloadCacheData();
        }

        public override Task DeletePost(Post post)
        {
            string filePath = GetFilePath(post);

            if (File.Exists(filePath))
            {
                File.Delete(filePath);
            }

            if (_cache.Contains(post))
            {
                _cache.Remove(post);
            }
            ReloadCacheData();

            return Task.CompletedTask;
        }


        #endregion

        #region 不屬於介面方法(資料存取)
        private void Initialize()
        {
            LoadPosts();
            SortCache();
            LoadPostGroupByCat();
        }

        private void ReloadCacheData()
        {
            LoadPostGroupByCat();
        }

        private void LoadPostGroupByCat()
        {
            _cachePostGroupByCat.Clear();
            _cache
            .SelectMany(cat => cat.Categories,
                (post, cat) => new PostGroupCatsViewModel
                {
                    Title = post.Title,
                    Slug = post.Slug,
                    CatName = cat.MiniBlogToLowerInvariant()
                }
            )
            .GroupBy(g => g.CatName).ToList().ForEach(p=>
            {
                _cachePostGroupByCat.Add(p);
            });
        }

        private void LoadPosts()
        {
            if (!Directory.Exists(_folder))
            {
                Directory.CreateDirectory(_folder);
            }

            // Can this be done in parallel to speed it up?
            foreach (string file in Directory.EnumerateFiles(_folder, "*.xml", SearchOption.TopDirectoryOnly))
            {
                XElement doc = XElement.Load(file);

                Post post = new Post
                {
                    ID = Path.GetFileNameWithoutExtension(file),
                    Title = ReadValue(doc, "title"),
                    Excerpt = ReadValue(doc, "excerpt"),
                    Content = ReadValue(doc, "content"),
                    MarkDownContent = ReadValue(doc, "markDownContent"),
                    Slug = ReadValue(doc, "slug").MiniBlogToLowerInvariant(),
                    PubDate = DateTime.Parse(ReadValue(doc, "pubDate")).ToUniversalTime(),
                    LastModified = DateTime.Parse(ReadValue(doc, "lastModified", DateTime.UtcNow.ToString(CultureInfo.InvariantCulture))).ToUniversalTime(),
                    IsPublished = bool.Parse(ReadValue(doc, "ispublished", "true")),
                    IsMarkDown = bool.Parse(ReadValue(doc, "isMarkDown", "false")),
                };

                LoadCategories(post, doc);
                LoadComments(post, doc);
                _cache.Add(post);
            }
        }

        private static void LoadCategories(Post post, XElement doc)
        {
            XElement categories = doc.Element("categories");
            if (categories == null)
            {
                return;
            }

            List<string> list = new List<string>();

            foreach (var node in categories.Elements("category"))
            {
                list.Add(node.Value);
            }

            post.Categories = list.ToArray();
        }

        private static void LoadComments(Post post, XElement doc)
        {
            var comments = doc.Element("comments");

            if (comments == null)
            {
                return;
            }

            foreach (var node in comments.Elements("comment"))
            {
                Comment comment = new Comment()
                {
                    ID = ReadAttribute(node, "id"),
                    Author = ReadValue(node, "author"),
                    Email = ReadValue(node, "email"),
                    IsAdmin = bool.Parse(ReadAttribute(node, "isAdmin", "false")),
                    Content = ReadValue(node, "content"),
                    PubDate = DateTime.Parse(ReadValue(node, "date", "2000-01-01")),
                };

                post.Comments.Add(comment);
            }
        }
        #endregion

        #region 不屬於介面方法(輔助)
        private string GetFilePath(Post post)
        {
            return Path.Combine(_folder, post.ID + ".xml");
        }

        private static string ReadValue(XElement doc, XName name, string defaultValue = "")
        {
            if (doc.Element(name) != null)
            {
                return doc.Element(name)?.Value;
            }

            return defaultValue;
        }

        private static string ReadAttribute(XElement element, XName name, string defaultValue = "")
        {
            if (element.Attribute(name) != null)
            {
                return element.Attribute(name)?.Value;
            }

            return defaultValue;
        }

        private static string FormatDateTime(DateTime dateTime)
        {
            const string UTC = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff'Z'";

            return dateTime.Kind == DateTimeKind.Utc
                ? dateTime.ToString(UTC)
                : dateTime.ToUniversalTime().ToString(UTC);
        }

        private static string ReplaceHexadecimalSymbols(string txt)
        {
            if (txt == null)
                return null;
            string r = "[\x00-\x08\x0B\x0C\x0E-\x1F\x26]";
            return Regex.Replace(txt, r, "", RegexOptions.Compiled);
        }
        #endregion
    }
}

<|start_filename|>src/Views/Blog/Index.cshtml<|end_filename|>
﻿@model IEnumerable<Post>
@inject IOptionsSnapshot<BlogSettings> settings
@{
    int currentPage = int.Parse(ViewContext.RouteData.Values["page"] as string ?? "0");
    var SidebarList = ViewData["SidebarList"] != null ? ViewData["SidebarList"] as List<IGrouping<string, PostGroupCatsViewModel>> : new List<IGrouping<string, PostGroupCatsViewModel>>();
}

@foreach (var post in Model)
{
    await Html.RenderPartialAsync("Post.cshtml", post);
}

@section Menu{


    @if (SidebarList.Count == 1)
    {
        var items = SidebarList.SingleOrDefault();
        <div class="w3-container">
            <h4>
                @(items.Key) <br> (@items.Count() 篇文章)
            </h4>
        </div>
        @foreach (var post in items.OrderBy(o => o.Title, new PostTitleComparer()))
        {
            <div class="w3-container">
                <a class="w3-bar-item w3-button" asp-controller="Blog" asp-action="Post"
                   asp-route-Slug="@post.Slug"
                   asp-route-page="">@post.Title</a>
            </div>
        }
    }
    else
    {
        <div class="w3-container">
            <h4>
                目錄
            </h4>
        </div>
        @foreach (var item in SidebarList)
        {
            <div class="w3-container">
                <a class="w3-bar-item w3-button" asp-controller="Blog" asp-action="category"
                   asp-route-category="@item.Key"
                   asp-route-page="">@item.Key (@item.Count() 篇文章)</a>
            </div>
        }
    }


}

<nav class="pagination container" aria-label="Pagination">
    @if (Model.Any())
    {
        <a rel="prev" href="@ViewData["prev"]" title="Older posts">&laquo; Older</a>
    }
    @if (currentPage > 0)
    {
        <a rel="next" href="@ViewData["next"]" title="Newer posts">Newer &raquo;</a>
    }
    <br /><br />

    @section Head {
        @if (Model.Any())
        {
            <link rel="prev" href="@ViewData["prev"]" />
        }
        @if (currentPage > 0)
        {
            <link rel="next" href="@ViewData["next"]" />
        }
    }
</nav>

@{await Html.RenderPartialAsync("_SocialSharing", ViewData["Title"]);}


<|start_filename|>src/Common/PostTitleComparer.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace Miniblog.Core
{
    //邏輯:
    //	1.實作 `IComparer` 介面
    public class PostTitleComparer : IComparer<string>
    {
        //	2.使用正則抓出`()`裡面的數字值
        static int UseRegexGetTitleNumber(string text)
        {
            Match match = Regex.Match(text, @"(\d+)");
            if (match == null)
                return 0;
            int value;
            if (!int.TryParse(match.Value, out value))
                return 0;
            return value;
        }

        //	3.實作` Compare 比較方法`，如果前者大於後者返回1整數，如果前者等於後者返回0整數，如果前者小於後者返回 - 1。藉此來比較大小作排序。
        public int Compare(string s1, string s2)
        {
            var s1Val = UseRegexGetTitleNumber(s1);
            var s2Val = UseRegexGetTitleNumber(s2);
            if (s1Val > s2Val) return 1;
            if (s1Val < s2Val) return -1;
            if (s1Val == s2Val) return 0;
            return string.Compare(s1, s2, true);
        }
    }
}


<|start_filename|>src/Extensions/MiniBlogStringExtensions.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Miniblog.Core
{
    public static class MiniBlogStringExtensions
    {
        private static bool toLowerInvariant = true;
        public static bool GetToLowerInvariant() => toLowerInvariant;
        public static void SetToLowerInvariant(bool value) => toLowerInvariant = value;

        public static string MiniBlogToLowerInvariant(this string content)
        {
            if (toLowerInvariant)
                return content.ToLowerInvariant();
            else
                return content;
        }
    }
}


<|start_filename|>src/appsettings.json<|end_filename|>
﻿{
  "forcessl": false,
  "toLowerInvariant": false,
  "user": {
    "username": "demo",
    // Generate a new password hash with salt here https://onlinehasher.azurewebsites.net/
    "password": "<PASSWORD>", // this password is "<PASSWORD>"
    "salt": "any custom string"
  },
  "webOptimizer": {
    "enableCaching": true
  },
  "blog": {
    "owner": "暐翰",
    "postsPerPage": 10,
    "commentsCloseAfterDays": 7,
    "ShowLogin": true,
    "MarkDown": true,
    "ShowPostTitle": false,
    "SQLiteConnString": "", //DataSource=app.db
    "MSSQLConnString": "",
    "ITIronManArticleURI": [ "https://ithelp.ithome.com.tw/users/20105988/ironman/1622" ],
    "ITIronManKeyCode": "sdfdsfww532513ffsdfdsfsdvcxvxcv",
    "UseITIronManLocalLoader": false,
    "ITIronManLocalLoaderInterval": 24
  },
  "cdn": {
    "url": ""
  },
  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Warning"
    }
  }
}


<|start_filename|>src/BlogSettings.cs<|end_filename|>
﻿using Miniblog.Core.Services;

namespace Miniblog.Core
{
    public class Setting
    {
        public static BlogSettings sectionBlog;
        public static IBlogService blogService;
    }

    public class BlogSettings
    {
        public string Owner { get; set; } = "The Owner";
        public int PostsPerPage { get; set; } = 2;
        public int CommentsCloseAfterDays { get; set; } = 10;
        public bool ShowLogin { get; set; } = true;
        public bool MarkDown { get; set; } = false;
        public bool ShowPostTitle { get; set; } = true;
        public string SQLiteConnString { get; set; } = "";
        public string MSSQLConnString { get; set; } = "";
        public string[] ITIronManArticleURI { get; set; }
        public string ITIronManKeyCode { get; set; }="";
        public string[] ITIronManReplaceString { get; set; }
        public bool UseITIronManLocalLoader { get; set; } = false;
        public int ITIronManLocalLoaderInterval { get; set; } = 24;/*預設24小時*/
    }
}


<|start_filename|>src/Models/PostGroupCatsViewModel.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Miniblog.Core.Models
{
    public class PostGroupCatsViewModel
    {
        public string Title { get; set; }
        public string CatName { get; set; }
        public string Slug { get; set; }
    }
}


<|start_filename|>src/Services/IBlogService.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Miniblog.Core.Models;

namespace Miniblog.Core.Services
{
    public interface IBlogService
    {
        Task<IEnumerable<Post>> GetPosts(int count, int skip = 0);

        Task<IEnumerable<Post>> GetPostsByCategory(string category);

        Task<IEnumerable<IGrouping<string, PostGroupCatsViewModel>>> GetPostsGroupbyCategory(string category);

        Task<IEnumerable<Post>> GetPostsByCat(string book);

        Task<Post> GetPostBySlug(string slug);

        Task<Post> GetPostById(string id);

        Task<IEnumerable<string>> GetCategories();

        Task SavePost(Post post);

        Task DeletePost(Post post);

        Task<string> SaveFileAsync(byte[] bytes, string fileName, string suffix = null);
    }

    public abstract class InMemoryBlogServiceBase : IBlogService
    {
        protected const string POSTS = "Posts";
        protected const string FILES = "files";

        protected  List<Post> _cache = new List<Post>();
        protected  List<IGrouping<string, PostGroupCatsViewModel>> _cachePostGroupByCat = new List<IGrouping<string, PostGroupCatsViewModel>>();
        protected  IHttpContextAccessor _contextAccessor;
        protected  string _folder;

        public InMemoryBlogServiceBase(){}

        public virtual Task<IEnumerable<Post>> GetPosts(int count, int skip = 0)
        {
            bool isAdmin = IsAdmin();

            var posts = _cache
                .Where(p => p.PubDate <= DateTime.UtcNow && (p.IsPublished || isAdmin))
                .Skip(skip)
                .Take(count);

            return Task.FromResult(posts);
        }

        public virtual Task<IEnumerable<Post>> GetPostsByCategory(string category)
        {
            bool isAdmin = IsAdmin();

            var posts = from p in _cache
                        where p.PubDate <= DateTime.UtcNow && (p.IsPublished || isAdmin)
                        where p.Categories.Contains(category, StringComparer.OrdinalIgnoreCase)
                        select p;

            return Task.FromResult(posts);
        }

        public virtual Task<Post> GetPostBySlug(string slug)
        {
            var post = _cache.FirstOrDefault(p => p.Slug.Equals(slug, StringComparison.OrdinalIgnoreCase));
            bool isAdmin = IsAdmin();

            if (post != null && post.PubDate <= DateTime.UtcNow && (post.IsPublished || isAdmin))
            {
                return Task.FromResult(post);
            }

            return Task.FromResult<Post>(null);
        }

        public virtual Task<IEnumerable<Post>> GetPostsByCat(string cat)
        {
            bool isAdmin = IsAdmin();

            var posts = from p in _cache
                        where p.PubDate <= DateTime.Now && (p.IsPublished || isAdmin)
                        where p.Categories.Contains(cat.MiniBlogToLowerInvariant())
                        select p;

            return Task.FromResult(posts);
        }

        public virtual Task<Post> GetPostById(string id)
        {
            var post = _cache.FirstOrDefault(p => p.ID.Equals(id, StringComparison.OrdinalIgnoreCase));
            bool isAdmin = IsAdmin();

            if (post != null && post.PubDate <= DateTime.UtcNow && (post.IsPublished || isAdmin))
            {
                return Task.FromResult(post);
            }

            return Task.FromResult<Post>(null);
        }

        public virtual Task<IEnumerable<string>> GetCategories()
        {
            bool isAdmin = IsAdmin();

            var categories = _cache
                .Where(p => p.IsPublished || isAdmin)
                .SelectMany(post => post.Categories)
                .Select(cat => cat.MiniBlogToLowerInvariant())
                .Distinct();

            return Task.FromResult(categories);
        }

        public Task<IEnumerable<IGrouping<string, PostGroupCatsViewModel>>> GetPostsGroupbyCategory(string category)
        {
            bool isAdmin = IsAdmin();
            var postsGroup = _cachePostGroupByCat
                .Where(w => category == null ? true : w.Key == category.MiniBlogToLowerInvariant())
            ;
            return Task.FromResult(postsGroup);
        }

        public async Task<string> SaveFileAsync(byte[] bytes, string fileName, string suffix = null)
        {
            suffix = CleanFromInvalidChars(suffix ?? DateTime.UtcNow.Ticks.ToString());

            string ext = Path.GetExtension(fileName);
            string name = CleanFromInvalidChars(Path.GetFileNameWithoutExtension(fileName));

            string fileNameWithSuffix = $"{name}_{suffix}{ext}";

            string absolute = Path.Combine(_folder, FILES, fileNameWithSuffix);
            string dir = Path.GetDirectoryName(absolute);

            Directory.CreateDirectory(dir);
            using (var writer = new FileStream(absolute, FileMode.CreateNew))
            {
                await writer.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false);
            }

            return $"/{POSTS}/{FILES}/{fileNameWithSuffix}";
        }


        public abstract Task SavePost(Post post);

        public abstract Task DeletePost(Post post);

        #region 不屬於介面方法(輔助)
        public void SortCache()
        {
            _cache.Sort((p1, p2) => p2.PubDate.CompareTo(p1.PubDate));
        }

        protected bool IsAdmin()
        {
            return _contextAccessor.HttpContext?.User?.Identity.IsAuthenticated == true;
        }

        private static string CleanFromInvalidChars(string input)
        {
            // ToDo: what we are doing here if we switch the blog from windows
            // to unix system or vice versa? we should remove all invalid chars for both systems

            var regexSearch = Regex.Escape(new string(Path.GetInvalidFileNameChars()) + new string(Path.GetInvalidPathChars()));
            var r = new Regex($"[{regexSearch}]");
            return r.Replace(input, "");
        }


        #endregion

    }
}


<|start_filename|>src/Services/ITIronManSyncPostService.cs<|end_filename|>
﻿using AngleSharp.Parser.Html;
using Microsoft.Extensions.Hosting;
using Miniblog.Core.Helper;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;

namespace Miniblog.Core.Services
{

    public class ITIronManSyncPostTimedHostedService : IHostedService, IDisposable
    {
        private Timer _timer;

        public Task StartAsync(CancellationToken cancellationToken)
        {
            var time = TimeSpan.FromHours(Setting.sectionBlog.ITIronManLocalLoaderInterval);
            _timer = new Timer(DoWork, null, time, time);
            return Task.CompletedTask;
        }

        private void DoWork(object state)
        {
            if(!ITIronManSyncPostService.SyncPostAsync().GetAwaiter().IsCompleted)
                throw new Exception("Excute failed");
        }

        public Task StopAsync(CancellationToken cancellationToken)
        {
            _timer?.Change(Timeout.Infinite, 0);
            return Task.CompletedTask;
        }

        public void Dispose()
        {
            _timer?.Dispose();
        }
    }

    public class ITIronManSyncPostService
    {
        private static readonly HtmlParser _parser = new HtmlParser();
        public IList<Post> Posts { get; set; } = new List<Post>();
        private string _url { get; set; }

        public static async Task SyncPostAsync()
        {
            var sectionBlog = Setting.sectionBlog;
            var blogService = Setting.blogService;
            foreach (var item in sectionBlog.ITIronManArticleURI)
            {
                var lstITposts = ITIronManSyncPostService.GetITIronManPosts(item).Result;
                foreach (var itpost in lstITposts)
                {
                    //檢查有沒有資料，如果有資料更新動作
                    var id = itpost.link.Replace("https://ithelp.ithome.com.tw/articles/", "");
                    var post = blogService.GetPostById(id).Result?? new Miniblog.Core.Models.Post() { ID = id };
                    post.Content = $"IT鐵人賽連結:<a href='{itpost.link}'>{itpost.link}</a><hr/>" + itpost.Content;
                    post.Excerpt = $"IT鐵人賽連結:<a href='{itpost.link}'>{itpost.link}</a><hr/>" + HtmlHelper.HtmlInnerText(itpost.Content);
                    var title = itpost.Title;
                    foreach (var replaceString in sectionBlog.ITIronManReplaceString)
                        title = title.Replace(replaceString, "");
                    post.Title = title;
                    post.Categories = new string[] { itpost.Article };
                    post.IsPublished = true;
                    post.IsMarkDown = true;
                    post.PubDate = itpost.PubDate;
                    post.Slug = id;
                    await blogService.SavePost(post);
                };
            }
        }

        public async static Task<IList<Post>> GetITIronManPosts(string url)
        {
            var itironman = new ITIronManSyncPostService();
            itironman._url = url;
            await itironman.ExecuteAsync();
            return itironman.Posts;
        }

        private async Task ExecuteAsync()
        {
            //因為IT鐵人賽只需要三十篇文章，每頁10篇文章，抓取頁數取4頁就好
            for (int i = 1; i < 4; i++)
            {
                await GetITIronManPostsAsync(_url + $"?page={i}");
            }
        }

        private async Task GetITIronManPostsAsync(string url)
        {
            var htmlContent = (await GetAsync(url));
            var document = _parser.Parse(htmlContent);

            //獲取鐵人賽主題
            var article = document.QuerySelector(".qa-list__title--ironman");
            article.RemoveChild(article.QuerySelector("span"));/*移除系列文字*/
            var articleText = article.TextContent.Trim();

            //獲取鐵人賽:發布日期、標題、內容、連結
            var allpost = document.QuerySelectorAll(".profile-list__content");
            foreach (var postInfo in allpost)
            {
                var post = new Post();

                var titleAndLinkDom = postInfo.QuerySelector(".qa-list__title>a");
                post.Title = titleAndLinkDom.InnerHtml.Trim();
                post.link = titleAndLinkDom.GetAttribute("href").Trim();
                post.Content = GetPostContentAsync(post.link).Result.Trim();
                post.PubDate = DateTime.Parse(postInfo.QuerySelector(".qa-list__info>.qa-list__info-time").GetAttribute("title").Trim());
                post.Article = articleText;

                Posts.Add(post);
            }
        }

        private async Task<string> GetPostContentAsync(string posturl)
        {
            var htmlContent = (await GetAsync(posturl));
            var document = _parser.Parse(htmlContent);
            return document.QuerySelectorAll(".markdown__style").FirstOrDefault().InnerHtml;
        }

        public async Task<string> GetAsync(string uri)
        {
            HttpWebRequest request = (HttpWebRequest)WebRequest.Create(uri);
            request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;

            using (HttpWebResponse response = (HttpWebResponse)await request.GetResponseAsync())
            using (Stream stream = response.GetResponseStream())
            using (StreamReader reader = new StreamReader(stream))
            {
                return await reader.ReadToEndAsync();
            }
        }

        public class Post
        {
            public string Title { get; set; }
            public string link { get; set; }
            public string Content { get; set; }
            public string Article { get; set; }
            public DateTime PubDate { get; set; }
        }
    }
}


<|start_filename|>src/Helper/HtmlHelper.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AngleSharp;
using AngleSharp.Parser.Html;

namespace Miniblog.Core.Helper
{
    public class HtmlHelper
    {
        public static string HtmlInnerText(string content)
        {
            if (content == null)
            {
                throw new ArgumentNullException(nameof(content));
            }
            var parser = new HtmlParser();
            var document = parser.Parse(content);
            return string.Join("",document.DocumentElement.TextContent.Take(250));
        }
    }
}


<|start_filename|>src/Helper/SqlHelper.cs<|end_filename|>
﻿using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;

namespace Miniblog.Core.Helper
{
    public abstract class SqlHelper
    {
        /*連線字串跟DBPrividerFactory設定在Startup.cs*/
        public static string connectionString;
        public static System.Data.Common.DbProviderFactory dbProviderFactory ;
        public static IDbConnection CreateDefaultConnection()
        {
            var conn = dbProviderFactory.CreateConnection();
            conn.ConnectionString = connectionString;
            conn.Open();
            return conn;
        }
    }
}


<|start_filename|>src/Services/MSSqlBlogService.cs<|end_filename|>
﻿using Dapper;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Miniblog.Core.Helper;
using Miniblog.Core.Models;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Transactions;

namespace Miniblog.Core.Services
{
    public class MSSqlBlogService : InMemoryBlogServiceBase
    {
        public MSSqlBlogService(IHostingEnvironment env, IHttpContextAccessor contextAccessor)
        {
            _folder = Path.Combine(env.WebRootPath, POSTS);
            _contextAccessor = contextAccessor;

            Initialize();
        }

        #region 改寫部分(override)
        public override async Task SavePost(Post post)
        {
            post.LastModified = DateTime.UtcNow;
            using (var conn = SqlHelper.CreateDefaultConnection())
            using (var ts = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
            {
                if (conn.Query<bool>("select top 1 1 from post where id = @id", new { @id = post.ID }).SingleOrDefault())
                {
                    await conn.ExecuteAsync(@"
                        UPDATE Post
                        SET Title = @Title,Slug = @Slug,Excerpt = @Excerpt,Content = @Content
                            ,PubDate = @PubDate,LastModified = @LastModified,IsPublished = @IsPublished
                            ,MarkDownContent = @MarkDownContent,IsMarkDown = @IsMarkDown
                        WHERE ID = @ID
                    ", post);
                }
                else
                {
                    await conn.ExecuteAsync(@"
                        INSERT INTO Post(ID,Title,Slug,Excerpt,Content,PubDate,LastModified,IsPublished,MarkDownContent,IsMarkDown)
                        VALUES (@ID,@Title,@Slug,@Excerpt,@Content,@PubDate,@LastModified,@IsPublished,@MarkDownContent,@IsMarkDown)
                    ", post);
                }
                await conn.ExecuteAsync(@"
                        Delete from Categories where PostID = @ID ;
                    ", post);
                var cats = post.Categories.Select(s => new { PostID = post.ID, Name = s }).ToList();
                await conn.ExecuteAsync(@"
                    INSERT INTO Categories (PostID ,Name) 
                    VALUES (@PostID ,@Name)
                ", cats);

                ts.Complete();
            }

            /* 如果有資料做更新，沒資料才做新增
             * 補充:原本寫法: !_cache.Contains(post)，會造成假如是創立新的物件
             * ，但ID值是一樣的，可以重複新增到Cache
             */
            if (!_cache.Any(w => w.ID == post.ID))
            {
                _cache.Add(post);
                SortCache();
            }
            ReloadCacheData();
        }

        public override Task DeletePost(Post post)
        {
            using (var conn = SqlHelper.CreateDefaultConnection())
            {
                conn.Execute(@"
                    begin tran;
                    delete Comment where PostID = @ID;
                    delete Categories where PostID = @ID;
                    delete Post where ID = @ID;
                    commit;
                ", new { ID = post.ID });
            }

            if (_cache.Contains(post))
            {
                _cache.Remove(post);
            }
            ReloadCacheData();

            return Task.CompletedTask;
        }
        #endregion

        #region 不屬於介面方法(資料存取)

        public async Task SaveComment(Post post, Comment comment)
        {
            using (var conn = SqlHelper.CreateDefaultConnection())
            {
                await conn.ExecuteAsync(@"
                    INSERT INTO Comment (ID ,Author ,Email ,Content ,PubDate ,IsAdmin ,PostID) 
                    VALUES (@ID ,@Author ,@Email ,@Content ,@PubDate ,@IsAdmin,@PostID)
                ", new
                {
                    @ID = comment.ID,
                    @Author = comment.Author,
                    @Email = comment.Email,
                    @Content = comment.Content,
                    @PubDate = comment.PubDate,
                    @IsAdmin = comment.IsAdmin,
                    @PostID = post.ID
                });
            }
        }

        public async Task DeleteComment(Post post, Comment comment)
        {
            using (var conn = SqlHelper.CreateDefaultConnection())
            {
                await conn.ExecuteAsync(@" Delete Comment where ID = @ID ", new { @ID = comment.ID });
            }
        }

        private void LoadCategories(Post post, System.Data.IDbConnection conn)
        {
            List<string> list = conn.Query<string>(@"
                select Name from Categories where postid = @ID 
            ", new { @ID = post.ID }).ToList();
            post.Categories = list.ToArray();
        }

        private void LoadComments(Post post, System.Data.IDbConnection conn)
        {
            var comments = conn.Query<Comment>(@"
                select * from Comment where postid = @ID 
            ", new { @ID = post.ID }).ToList();
            foreach (var comment in comments)
            {
                post.Comments.Add(comment);
            }
        }

        private void Initialize()
        {
            LoadPosts();
            SortCache();
            LoadPostGroupByCat();
        }

        private void ReloadCacheData()
        {
            LoadPostGroupByCat();
        }

        private void LoadPostGroupByCat()
        {
            _cachePostGroupByCat.Clear();
            _cache
            .SelectMany(cat => cat.Categories,
                (post, cat) => new PostGroupCatsViewModel
                {
                    Title = post.Title,
                    Slug = post.Slug,
                    CatName = cat.MiniBlogToLowerInvariant()
                }
            )
            .GroupBy(g => g.CatName).ToList().ForEach(p =>
            {
                _cachePostGroupByCat.Add(p);
            });
        }

        private void LoadPosts()
        {
            using (var conn = SqlHelper.CreateDefaultConnection())
            {
                var posts = conn.Query<Post>("select * from Post");
                foreach (var post in posts)
                {
                    LoadCategories(post, conn);
                    LoadComments(post, conn);
                    _cache.Add(post);
                }
            }
        }
        #endregion

        #region 不屬於介面方法(輔助)
        #endregion
    }
}


<|start_filename|>src/Views/Shared/CustomSideMenu.cshtml<|end_filename|>
﻿
@Html.Raw("<br/><br/><br/><br/><hr>")
<div class="sidesection w3-light-grey" style="margin-left:auto;margin-right:auto;max-width:230px">
    <div class="w3-dark-grey">
        <h4>　關於我</h4>
    </div>
    <div class="w3-container">
        <p>這是測試</p>
   </div>
</div>
@Html.Raw("<hr>")
<div class="sidesection w3-light-grey" style="margin-left:auto;margin-right:auto;max-width:230px">
    <div class="w3-dark-grey">
        <h4>　公告</h4>
    </div>
    <div class="w3-container">
        <p>
            系統公告
        </p>
    </div>
</div>

@Html.Raw("<br/><br/><br/><br/><br/><br/><hr>")