From 39b7fda76c44a45843e730033ff61c1feb0f12a9 Mon Sep 17 00:00:00 2001 From: Edi Wang Date: Sun, 7 Jun 2026 17:38:47 +0800 Subject: [PATCH 01/20] Bump Azure.Storage.Blobs to 12.29.0 Update the Azure.Storage.Blobs package reference from 12.28.0 to 12.29.0 in the Moonglade.ImageStorage project. Run CI/build and review the package changelog for any relevant fixes or breaking changes. --- src/Moonglade.ImageStorage/Moonglade.ImageStorage.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Moonglade.ImageStorage/Moonglade.ImageStorage.csproj b/src/Moonglade.ImageStorage/Moonglade.ImageStorage.csproj index f0274b82d..21fc05278 100644 --- a/src/Moonglade.ImageStorage/Moonglade.ImageStorage.csproj +++ b/src/Moonglade.ImageStorage/Moonglade.ImageStorage.csproj @@ -10,6 +10,6 @@ - + \ No newline at end of file From 0cd0b7b5253afe0c19dd25310885b83903083ca0 Mon Sep 17 00:00:00 2001 From: Edi Wang Date: Sun, 7 Jun 2026 17:38:56 +0800 Subject: [PATCH 02/20] Add Kusto syntax highlighter and register it Introduce a new highlight.js language definition for Kusto (KQL) at src/.../highlight.kusto.js. The file declares keywords, types, built-in functions and token patterns (strings, numbers, variables, comments, etc.). Also update post.highlight.mjs to import and register the new 'kusto' language so Kusto code blocks are highlighted in posts. --- .../wwwroot/js/3rd/highlight.kusto.js | 261 ++++++++++++++++++ .../wwwroot/js/app/post.highlight.mjs | 4 +- 2 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/Moonglade.Web/wwwroot/js/3rd/highlight.kusto.js diff --git a/src/Moonglade.Web/wwwroot/js/3rd/highlight.kusto.js b/src/Moonglade.Web/wwwroot/js/3rd/highlight.kusto.js new file mode 100644 index 000000000..1875a3e1a --- /dev/null +++ b/src/Moonglade.Web/wwwroot/js/3rd/highlight.kusto.js @@ -0,0 +1,261 @@ +export default function (hljs) { + const KUSTO_KEYWORDS = [ + 'alias', + 'and', + 'as', + 'asc', + 'between', + 'by', + 'contains', + 'contains_cs', + 'count', + 'declare', + 'default', + 'desc', + 'distinct', + 'evaluate', + 'extend', + 'facet', + 'find', + 'fork', + 'from', + 'getschema', + 'has', + 'has_all', + 'has_any', + 'has_cs', + 'in', + 'invoke', + 'join', + 'kind', + 'let', + 'limit', + 'lookup', + 'make-series', + 'materialize', + 'matches', + 'mv-apply', + 'mv-expand', + 'not', + 'nulls', + 'of', + 'on', + 'or', + 'order', + 'parse', + 'parse-kv', + 'parse-where', + 'partition', + 'print', + 'project', + 'project-away', + 'project-keep', + 'project-rename', + 'project-reorder', + 'range', + 'reduce', + 'render', + 'restrict', + 'sample', + 'sample-distinct', + 'scan', + 'search', + 'serialize', + 'set', + 'sort', + 'step', + 'summarize', + 'take', + 'top', + 'top-hitters', + 'top-nested', + 'to', + 'union', + 'where', + 'with' + ]; + + const KUSTO_TYPES = [ + 'bool', + 'boolean', + 'date', + 'datetime', + 'decimal', + 'double', + 'dynamic', + 'float', + 'guid', + 'int', + 'int8', + 'int16', + 'int32', + 'int64', + 'long', + 'real', + 'string', + 'time', + 'timespan', + 'uint', + 'uint8', + 'uint16', + 'uint32', + 'uint64', + 'ulong' + ]; + + const KUSTO_FUNCTIONS = [ + 'abs', + 'ago', + 'array_concat', + 'array_length', + 'array_sort_asc', + 'array_sort_desc', + 'avg', + 'avgif', + 'bag_keys', + 'bag_pack', + 'bin', + 'case', + 'ceiling', + 'coalesce', + 'countif', + 'countof', + 'dcount', + 'dcountif', + 'datetime_add', + 'datetime_diff', + 'datetime_part', + 'dayofmonth', + 'dayofweek', + 'endofday', + 'endofmonth', + 'endofweek', + 'endofyear', + 'extract', + 'extract_all', + 'extract_json', + 'floor', + 'format_datetime', + 'format_timespan', + 'gettype', + 'hash', + 'hash_md5', + 'hash_sha1', + 'hash_sha256', + 'iff', + 'iif', + 'indexof', + 'isempty', + 'isfinite', + 'isinf', + 'isnan', + 'isnotempty', + 'isnotnull', + 'isnull', + 'make_bag', + 'make_list', + 'make_set', + 'max', + 'maxif', + 'min', + 'minif', + 'now', + 'pack', + 'pack_all', + 'parse_csv', + 'parse_json', + 'parse_url', + 'percentile', + 'percentiles', + 'pow', + 'rand', + 'replace_regex', + 'replace_string', + 'round', + 'row_number', + 'split', + 'startofday', + 'startofmonth', + 'startofweek', + 'startofyear', + 'strcat', + 'strcat_delim', + 'strlen', + 'substring', + 'sum', + 'sumif', + 'take_any', + 'tolower', + 'tostring', + 'toupper', + 'translate', + 'trim', + 'typeof', + 'url_decode', + 'url_encode' + ]; + + return { + name: 'Kusto', + aliases: ['kql'], + case_insensitive: true, + keywords: { + $pattern: /[a-zA-Z_][a-zA-Z0-9_.-]*/, + keyword: KUSTO_KEYWORDS, + type: KUSTO_TYPES, + built_in: KUSTO_FUNCTIONS, + literal: 'true false null' + }, + contains: [ + hljs.COMMENT('//', '$'), + { + className: 'string', + variants: [ + { + begin: /@"/, + end: /"/, + contains: [{ begin: /""/ }] + }, + { + begin: /@'/, + end: /'/, + contains: [{ begin: /''/ }] + }, + { + begin: /[hH]"/, + end: /"/, + contains: [{ begin: /\\./ }] + }, + { + begin: /[hH]'/, + end: /'/, + contains: [{ begin: /\\./ }] + }, + hljs.QUOTE_STRING_MODE, + hljs.APOS_STRING_MODE, + { + begin: /```/, + end: /```/ + }, + { + begin: /~~~/, + end: /~~~/ + } + ] + }, + { + className: 'number', + begin: /\b\d+(\.\d+)?\s*(d|h|hr|m|min|minute|minutes|s|sec|second|seconds|ms|milli|millisecond|milliseconds|micro|microsecond|microseconds|nano|nanosecond|nanoseconds|tick|ticks)\b/ + }, + hljs.C_NUMBER_MODE, + { + className: 'variable', + begin: /\$[a-zA-Z_]\w*/ + }, + { + className: 'keyword', + begin: /\bmatches\s+regex\b/ + } + ] + }; +} diff --git a/src/Moonglade.Web/wwwroot/js/app/post.highlight.mjs b/src/Moonglade.Web/wwwroot/js/app/post.highlight.mjs index 519425be1..1c033881c 100644 --- a/src/Moonglade.Web/wwwroot/js/app/post.highlight.mjs +++ b/src/Moonglade.Web/wwwroot/js/app/post.highlight.mjs @@ -1,4 +1,5 @@ import bicep from '../3rd/highlight.bicep.js' +import kusto from '../3rd/highlight.kusto.js' export function renderCodeHighlighter() { const pres = document.querySelectorAll('pre'); @@ -19,6 +20,7 @@ export function renderCodeHighlighter() { }); hljs.registerLanguage('bicep', bicep); + hljs.registerLanguage('kusto', kusto); const codeBlocks = document.querySelectorAll('pre code'); codeBlocks.forEach(block => { @@ -38,4 +40,4 @@ export function renderLaTeX(selector) { console.error(error); } }); -} \ No newline at end of file +} From 70b3157573cf376a02ef1b6d4503bb167f0fb6e7 Mon Sep 17 00:00:00 2001 From: Edi Wang Date: Sun, 7 Jun 2026 19:44:57 +0800 Subject: [PATCH 03/20] Add Kusto code sample language; bump version Add Kusto to TinyMCE codesample_languages so authors can select it in the editor (src/Moonglade.Web/wwwroot/js/app/admin.editor.module.mjs). Update AGENTS.md with guidance to register new highlight.js languages before hljs.highlightElement in src/Moonglade.Web/wwwroot/js/app/post.highlight.mjs and add them to the editor list. Bump AssemblyVersion/FileVersion to 15.17.0 and set Version to 15.17.0-preview.1 in src/Directory.Build.props. --- AGENTS.md | 1 + src/Directory.Build.props | 6 +++--- src/Moonglade.Web/wwwroot/js/app/admin.editor.module.mjs | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 450ea3f7f..40557246a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,7 @@ The solution file is `src/Moonglade.slnx`. The root `README.md` is the main depl - Public and admin pages are primarily Razor Pages under `src/Moonglade.Web/Pages`. - Admin JSON operations are primarily API controllers under `src/Moonglade.Web/Controllers`. - Frontend code is built around the existing Razor layouts, Bootstrap, Alpine.js, TinyMCE, Monaco editor, and Tagify. Do not add a new frontend framework unless explicitly requested. +- Code block language support has two UI surfaces: the public post renderer and the admin TinyMCE code sample dialog. When adding a highlight.js language, register the language before `hljs.highlightElement` in `src/Moonglade.Web/wwwroot/js/app/post.highlight.mjs` and also add the language to `codesample_languages` in `src/Moonglade.Web/wwwroot/js/app/admin.editor.module.mjs`, otherwise authors cannot select it from the editor. - The TinyMCE language folder README says language packs should not be translated directly; use Crowdin instead. - Server-rendered UI text should consider resource files. Supported cultures are currently `en-US`, `zh-Hans`, `zh-Hant`, `de-DE`, and `ja-JP`. - Localization uses shared resources under `src/Moonglade.Web/Resources/Program.*.resx`. Razor pages inject `IStringLocalizer` as `SharedLocalizer`, and DataAnnotations display names are configured to use the same `Program` resource. When adding or renaming any `SharedLocalizer["..."]` key or `[Display(Name = "...")]` text, update all non-English resource files: `Program.zh-Hans.resx`, `Program.zh-Hant.resx`, `Program.de-DE.resx`, and `Program.ja-JP.resx`. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b4c66f26e..6f316cd01 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,8 +3,8 @@ Edi Wang edi.wang (C) 2026 edi.wang@outlook.com - 15.16.1 - 15.16.1 - 15.16.1 + 15.17.0 + 15.17.0 + 15.17.0-preview.1 \ No newline at end of file diff --git a/src/Moonglade.Web/wwwroot/js/app/admin.editor.module.mjs b/src/Moonglade.Web/wwwroot/js/app/admin.editor.module.mjs index 0776cd45b..e5578020c 100644 --- a/src/Moonglade.Web/wwwroot/js/app/admin.editor.module.mjs +++ b/src/Moonglade.Web/wwwroot/js/app/admin.editor.module.mjs @@ -147,6 +147,7 @@ export function loadTinyMCE(textareaSelector) { { text: 'JavaScript', value: 'javascript' }, { text: 'Json', value: 'json' }, { text: 'Kotlin', value: 'kotlin' }, + { text: 'Kusto', value: 'kusto' }, { text: 'LaTeX', value: 'latex' }, { text: 'Lua', value: 'lua' }, { text: 'Markdown', value: 'markdown' }, @@ -201,4 +202,4 @@ export function keepAlive() { console.info('live'); }); } -} \ No newline at end of file +} From d4093c2c2d0907b762d9a44e60befabd65b997dc Mon Sep 17 00:00:00 2001 From: Edi Wang Date: Mon, 8 Jun 2026 19:31:13 +0800 Subject: [PATCH 04/20] Add FEATURE-ROADMAP and English rule Add a new FEATURE-ROADMAP.md that documents feature research, prioritized batches, and implementation notes for Moonglade (prepared 2026-06-08). Update AGENTS.md to add a documentation/licensing guideline requiring repository content to be written in English except for localization resources (e.g., *.resx or third-party language packs). --- AGENTS.md | 1 + FEATURE-ROADMAP.md | 304 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 FEATURE-ROADMAP.md diff --git a/AGENTS.md b/AGENTS.md index 40557246a..75d02c8d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,7 @@ The solution file is `src/Moonglade.slnx`. The root `README.md` is the main depl ### Documentation And Licenses +- Repository content must be written in English unless the file is a localization resource, such as `src/Moonglade.Web/Resources/Program.*.resx` or third-party language pack files. Do not add non-English text to Markdown files, source code, comments, tests, configuration, or documentation outside localization resources. - The README states that this blogging system must not be used to serve users in mainland China or to publish content prohibited by Chinese law or any applicable regulations. - The repository license is GPL-3.0. TinyMCE includes a GPL-2.0-or-later license notice. Do not remove or rewrite third-party license files casually. - Do not add license or copyright headers unless explicitly requested. diff --git a/FEATURE-ROADMAP.md b/FEATURE-ROADMAP.md new file mode 100644 index 000000000..cc2fc5974 --- /dev/null +++ b/FEATURE-ROADMAP.md @@ -0,0 +1,304 @@ +# Moonglade Feature Research And Roadmap + +Prepared on: 2026-06-08 + +This document records the current feature research findings for Moonglade and provides a trackable roadmap for future enhancement work. + +## Current Feature Overview + +Moonglade is already a mature personal blogging platform for developers. Its existing capabilities include: + +- Posts: publishing, drafts, scheduled publishing, schedule cancellation, unpublishing, recycle bin, featured posts, outdated markers, and AI-assisted content markers. +- Pages: standalone pages, page-specific CSS, publish state, and recycle bin. +- Content organization: categories, tags, archives, featured lists, tag pages, and category lists. +- Editors: TinyMCE HTML editor, Monaco Markdown editor, drag-and-drop or pasted image upload, syntax highlighting, and LaTeX rendering. +- Comments: built-in comments, third-party comment HTML, review and approval, replies, closing comments on old posts, Gravatar, captcha, and word filtering. +- Social and protocol support: Webmention, RSS, Atom, OPML, OpenSearch, FOAF, Sitemap, and IndexNow. +- SEO: canonical URLs, meta description, keywords, Dublin Core, JSON-LD BlogPosting, and basic OpenGraph metadata. +- Images: local file system and Azure Blob Storage providers, image watermarking, original image retention, and CDN URL replacement. +- Admin portal: posts, pages, categories, tags, comments, menus, widgets, mentions, activity logs, and settings. +- Configuration: runtime blog settings, general settings, content settings, comment settings, notification settings, subscription settings, image settings, advanced settings, appearance settings, and custom menus. +- Authentication: local account authentication and Microsoft Entra ID. +- Operations: Docker, Azure deployment assets, health checks, startup initialization, automatic database migration, update checks, and background services. +- Databases: SQL Server and PostgreSQL. +- Tests: coverage across Features, Web, Auth, Configuration, ImageStorage, Syndication, Theme, Webmention, Moderation, BackgroundServices, and related projects. + +## Features Worth Enhancing + +### Search + +The current search experience mainly covers post titles and tags. It should become a more complete site search experience. + +- [ ] Search across titles, abstracts, body content, keywords, categories, and tags. +- [ ] Support pagination, sorting, and result highlighting. +- [ ] Support filters by category, tag, language, and publish date. +- [ ] Keep the implementation compatible with SQL Server and PostgreSQL. +- [ ] Add matching Features and Web tests. + +### Editing Experience + +Moonglade already has HTML and Markdown editors, but the writing and publishing workflow can be more robust. + +- [ ] Add draft autosave. +- [ ] Restore drafts after accidental refresh or navigation. +- [ ] Add live Markdown preview. +- [ ] Add SEO preview for title, abstract, canonical URL, and keywords. +- [ ] Add social sharing preview for OpenGraph and Twitter Card metadata. +- [ ] Add a pre-publish checklist for abstract, categories, tags, image alt text, external links, and feed inclusion. + +### Media Management + +Image upload and watermarking are already strong, but there is no full admin media library yet. + +- [ ] Add an admin media library page. +- [ ] Support image search, preview, and URL copying. +- [ ] Support deleting unused images. +- [ ] Detect orphaned images. +- [ ] Detect duplicate images. +- [ ] Support compression, thumbnails, and WebP or AVIF conversion. +- [ ] Make the upload size limit configurable. + +### Comment And Webmention Anti-Spam + +Moonglade already has captcha, review, word filtering, and Webmention validation. The next step is stronger moderation tooling. + +- [ ] Add an IP blacklist. +- [ ] Add an email blacklist. +- [ ] Add a domain blacklist. +- [ ] Add comment rate limiting. +- [ ] Add Webmention source rate limiting. +- [ ] Add trusted and blocked domain rules for Webmention. +- [ ] Improve batch approval and batch deletion workflows. +- [ ] Record review reasons and blocking reasons. + +### Analytics + +The current view count and request count features are useful, but they are closer to counters than analytics. + +- [ ] Add an admin analytics dashboard. +- [ ] Add popular post rankings. +- [ ] Add recent traffic trends. +- [ ] Add referrer statistics. +- [ ] Add search term statistics. +- [ ] Add bot filtering effectiveness statistics. +- [ ] Implement a multi-instance-safe counting strategy that does not rely on single-process locks. + +### Data Export And Migration + +The current export functionality mainly covers posts and pages. It should become a complete backup and migration capability. + +- [ ] Export comments. +- [ ] Export categories. +- [ ] Export tags. +- [ ] Export menus. +- [ ] Export widgets. +- [ ] Export configuration. +- [ ] Export themes and custom CSS. +- [ ] Export an image index. +- [ ] Add import and restore workflows. +- [ ] Add import validation and conflict handling. + +### Admin Diagnostics + +Moonglade already has an About page and `/health`. A more useful system status page would help production operations. + +- [ ] Check database connectivity. +- [ ] Check image storage. +- [ ] Check email delivery configuration. +- [ ] Check IndexNow configuration. +- [ ] Check Webmention send and receive capability. +- [ ] Check content moderation service health. +- [ ] Check background service state. +- [ ] Check cache state. +- [ ] Show update check status. + +### SEO And Sharing Quality + +Moonglade has a good SEO foundation. The next step is improving search engine and social platform presentation. + +- [ ] Support `og:image` for posts. +- [ ] Add Twitter Card metadata. +- [ ] Improve SEO metadata for category and tag pages. +- [ ] Add category-level and tag-level sitemap or feed support. +- [ ] Add redirect management for slug changes. +- [ ] Add structured data validation hints. + +### Localization Consistency + +Moonglade already has multilingual resources, but some Razor and JavaScript text may still be hard-coded in English. + +- [ ] Resource hard-coded text in admin pages. +- [ ] Resource hard-coded text in public pages. +- [ ] Resource JavaScript toast, confirm, and alert text. +- [ ] Update `Program.zh-Hans.resx`. +- [ ] Update `Program.zh-Hant.resx`. +- [ ] Update `Program.de-DE.resx`. +- [ ] Update `Program.ja-JP.resx`. + +### Security Hardening + +The security foundation is good. Admin protection and configuration risk detection can still be improved. + +- [ ] Add login failure rate limiting. +- [ ] Add administrator 2FA. +- [ ] Warn about default accounts and weak configuration. +- [ ] Add a UI for CSP and security response headers. +- [ ] Add stricter validation or a safe mode for custom Head and Foot scripts. +- [ ] Add secondary confirmation for sensitive admin operations. + +## New Features Worth Adding + +### Post Revision History + +- [ ] Save a snapshot for each post change. +- [ ] Support version diff. +- [ ] Support restoring historical versions. +- [ ] Record editor, edit time, and publish state. + +### Content Series + +- [ ] Add a Series entity. +- [ ] Allow posts to belong to one or more series. +- [ ] Add a series detail page. +- [ ] Add previous and next navigation within a series on post pages. +- [ ] Add a series progress navigation component. + +### Related Posts + +- [ ] Recommend posts based on tags. +- [ ] Recommend posts based on categories. +- [ ] Recommend posts based on keywords. +- [ ] Exclude the current post and unpublished posts. +- [ ] Make the recommendation count configurable. + +### Email Subscription And Newsletter + +- [ ] Add a subscriber entity. +- [ ] Send subscription confirmation email. +- [ ] Add unsubscribe links. +- [ ] Send post summaries after publishing. +- [ ] Add subscriber management in the admin portal. +- [ ] Record email delivery failures. + +### Social Publishing And Webhooks + +- [ ] Add generic webhook support. +- [ ] Trigger webhooks after publishing a post. +- [ ] Support retries and failure records. +- [ ] Optionally support Mastodon, Bluesky, X, LinkedIn, Discord, and Slack. + +### AI-Assisted Writing + +Moonglade currently has an AI-assisted content marker. Optional AI-assisted authoring features can build on that foundation. + +- [ ] Generate abstracts. +- [ ] Suggest titles. +- [ ] Suggest SEO keywords. +- [ ] Translate content. +- [ ] Check spelling and grammar. +- [ ] Keep every AI feature configurable, optional, and testable. + +### Full Backup And Restore + +- [ ] Add manual backup. +- [ ] Add scheduled backup. +- [ ] Back up to the local file system. +- [ ] Back up to Azure Blob Storage. +- [ ] Validate before restore. +- [ ] Add restore dry-run mode. + +### Multiple Administrators And Roles + +This is a larger architectural change and should be deferred until higher-priority work is complete. + +- [ ] Add user entities and admin management pages. +- [ ] Add Author, Editor, and Administrator roles. +- [ ] Bind posts to authors. +- [ ] Authorize admin pages by role. +- [ ] Track activity logs by user. + +## Suggested Execution Batches + +### Batch 1: Low Risk, High Value + +Goal: improve daily use and create a stronger foundation for future features. + +- [ ] Enhance site search. +- [ ] Expand complete export coverage. +- [ ] Add an admin system diagnostics page. +- [ ] Clean up hard-coded localization text. +- [ ] Add Features and Web tests. + +Acceptance criteria: + +- [ ] Search results are more complete and support pagination, filtering, and sorting. +- [ ] Exported data covers the main business entities. +- [ ] Admin users can inspect the health of critical dependencies. +- [ ] New or changed UI text is synchronized to non-English resource files. +- [ ] Affected test projects pass. + +### Batch 2: Content Production Efficiency + +Goal: make writing, editing, and publishing more reliable. + +- [ ] Add draft autosave. +- [ ] Add draft recovery. +- [ ] Add post revision history. +- [ ] Add Markdown preview. +- [ ] Add SEO and social sharing preview. +- [ ] Add a pre-publish checklist. +- [ ] Add the first version of the media library. + +Acceptance criteria: + +- [ ] Editing work is not lost after accidental refresh. +- [ ] Users can view and restore historical versions. +- [ ] The pre-publish flow can catch obvious content quality issues. +- [ ] Images can be managed from the admin portal. + +### Batch 3: Interaction And Growth + +Goal: improve reader interaction, subscription, and internal content discovery. + +- [ ] Enhance comment anti-spam. +- [ ] Enhance Webmention management. +- [ ] Add related posts. +- [ ] Add content series. +- [ ] Add newsletter subscription. +- [ ] Add publishing webhooks. + +Acceptance criteria: + +- [ ] Admin users can moderate comments and Webmentions more efficiently. +- [ ] Post pages guide readers to additional relevant content. +- [ ] Readers can subscribe to email updates. +- [ ] Publishing events can integrate with external services. + +### Batch 4: Production Resilience And Platform Capability + +Goal: improve production stability, security, and extensibility. + +- [ ] Make view counting safe for multi-instance deployments. +- [ ] Add scheduled backup and restore. +- [ ] Harden login security. +- [ ] Add configuration risk warnings. +- [ ] Add security response header configuration. +- [ ] Add optional AI-assisted writing. +- [ ] Evaluate multiple administrators and role-based authorization. + +Acceptance criteria: + +- [ ] Statistics do not obviously lose updates or conflict in multi-instance deployments. +- [ ] Main data can be backed up and restored reliably. +- [ ] Admin login security is stronger. +- [ ] AI features are disabled by default and have clear configuration, logging, and test boundaries. + +## Implementation Notes + +- Put business logic in the owning project such as `src/Moonglade.Features`, `src/Moonglade.Configuration`, or `src/Moonglade.Data`, and keep the Web layer thin. +- Consider both SQL Server and PostgreSQL for every database-related change. +- Treat post URLs, `RouteLink`, publish dates, slugs, feeds, sitemap, IndexNow, Webmention, and cache invalidation as high-risk boundaries. +- Follow the `IBlogSettings` pattern for new settings, and update defaults, initialization, settings pages, and resource files. +- Make new external service calls configurable, testable, logged, and preferably event-driven or backgrounded. +- When adding or renaming UI text, update the non-English resource files. +- Add or update tests in the matching test project for behavioral changes. At minimum, run the affected test project or the Web project build. From bf561885d6096fb3cd95346260c8cf77e8a3e165 Mon Sep 17 00:00:00 2001 From: Edi Wang Date: Mon, 8 Jun 2026 19:52:26 +0800 Subject: [PATCH 05/20] Enhance search: filtering, paging, sorting, UI Extend search capabilities end-to-end. Backend: expand SearchPostQuery into a richer query (keyword, paging, category, tag, language, date range, sort) and return SearchPostQueryResult (Posts + TotalRows). Add SearchPostSort enum and refactor handler to normalize keywords, apply keyword and facet filters, count total rows, apply sorting and paging. UI: update Search.cshtml to provide a search form with sort, category/tag/language/date filters, paging controls, and localization support; wire the page to call the enhanced query and build PagedResult. Presentation: improve _PostListEntry highlighting to HTML-encode text and safely mark matched terms. Tests: update expectations and add tests for pagination/sorting and facet filtering; adjust test helper to accept languageCode. Docs: permit non-English values in unit tests and update roadmap note to avoid scanning full post bodies. Overall: implements safer highlighting, richer filtering/sorting, and paginated search results with UI and tests. --- AGENTS.md | 2 +- FEATURE-ROADMAP.md | 2 +- .../Post/SearchPostQuery.cs | 131 ++++++++-- src/Moonglade.Web/Pages/Search.cshtml | 225 +++++++++++++++++- .../Pages/Shared/_PostListEntry.cshtml | 21 +- .../PostQueryTests.cs | 83 ++++++- 6 files changed, 420 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 75d02c8d0..2b53cd2d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ The solution file is `src/Moonglade.slnx`. The root `README.md` is the main depl ### Documentation And Licenses -- Repository content must be written in English unless the file is a localization resource, such as `src/Moonglade.Web/Resources/Program.*.resx` or third-party language pack files. Do not add non-English text to Markdown files, source code, comments, tests, configuration, or documentation outside localization resources. +- Repository content must be written in English unless the file is a localization resource, such as `src/Moonglade.Web/Resources/Program.*.resx` or third-party language pack files. Unit test data may also contain non-English values when the behavior under test requires them. Do not add non-English text to Markdown files, source code, comments, configuration, or documentation outside localization resources. - The README states that this blogging system must not be used to serve users in mainland China or to publish content prohibited by Chinese law or any applicable regulations. - The repository license is GPL-3.0. TinyMCE includes a GPL-2.0-or-later license notice. Do not remove or rewrite third-party license files casually. - Do not add license or copyright headers unless explicitly requested. diff --git a/FEATURE-ROADMAP.md b/FEATURE-ROADMAP.md index cc2fc5974..53eb5660a 100644 --- a/FEATURE-ROADMAP.md +++ b/FEATURE-ROADMAP.md @@ -29,7 +29,7 @@ Moonglade is already a mature personal blogging platform for developers. Its exi The current search experience mainly covers post titles and tags. It should become a more complete site search experience. -- [ ] Search across titles, abstracts, body content, keywords, categories, and tags. +- [ ] Search across titles, abstracts, keywords, categories, and tags without scanning full post bodies. - [ ] Support pagination, sorting, and result highlighting. - [ ] Support filters by category, tag, language, and publish date. - [ ] Keep the implementation compatible with SQL Server and PostgreSQL. diff --git a/src/Moonglade.Features/Post/SearchPostQuery.cs b/src/Moonglade.Features/Post/SearchPostQuery.cs index 347e60927..c71cf9846 100644 --- a/src/Moonglade.Features/Post/SearchPostQuery.cs +++ b/src/Moonglade.Features/Post/SearchPostQuery.cs @@ -4,43 +4,136 @@ namespace Moonglade.Features.Post; -public record SearchPostQuery(string Keyword) : IQuery>; +public enum SearchPostSort +{ + Newest = 0, + Oldest = 1, + TitleAscending = 2, + TitleDescending = 3 +} + +public record SearchPostQuery( + string Keyword, + int PageSize = 10, + int PageIndex = 1, + string CategorySlug = null, + string Tag = null, + string LanguageCode = null, + DateTime? StartDateUtc = null, + DateTime? EndDateUtc = null, + SearchPostSort Sort = SearchPostSort.Newest) : IQuery; -public class SearchPostQueryHandler(BlogDbContext db) : IQueryHandler> +public record SearchPostQueryResult(List Posts, int TotalRows); + +public class SearchPostQueryHandler(BlogDbContext db) : IQueryHandler { - public async Task> HandleAsync(SearchPostQuery request, CancellationToken ct) + public async Task HandleAsync(SearchPostQuery request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.Keyword)) { throw new ArgumentException("Keyword must not be null or whitespace.", nameof(request.Keyword)); } - var normalized = Regex.Replace(request.Keyword.Trim(), @"\s+", " "); - var words = normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (request.PageSize < 1) + { + throw new ArgumentOutOfRangeException(nameof(request.PageSize), "Page size must be greater than 0."); + } + + if (request.PageIndex < 1) + { + throw new ArgumentOutOfRangeException(nameof(request.PageIndex), "Page index must be greater than 0."); + } + + var words = NormalizeKeyword(request.Keyword); IQueryable query = db.Post .AsNoTracking() .Where(p => !p.IsDeleted && p.PostStatus == PostStatus.Published); - if (words.Length > 1) + query = ApplyKeywordFilter(query, words); + query = ApplyFacetFilters(query, request); + + var totalRows = await query.CountAsync(ct); + var posts = await ApplySorting(query, request.Sort) + .Skip((request.PageIndex - 1) * request.PageSize) + .Take(request.PageSize) + .SelectToDigest() + .ToListAsync(ct); + + return new(posts, totalRows); + } + + private static string[] NormalizeKeyword(string keyword) + { + var normalized = Regex.Replace(keyword.Trim(), @"\s+", " "); + return normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } + + private static IQueryable ApplyKeywordFilter(IQueryable query, IEnumerable words) + { + foreach (var word in words) { - foreach (var word in words) - { - query = query.Where(p => EF.Functions.Like(p.Title, "%" + word + "%")); - } + var keyword = word; + query = query.Where(p => + p.Title.Contains(keyword) || + (p.ContentAbstract != null && p.ContentAbstract.Contains(keyword)) || + (p.Keywords != null && p.Keywords.Contains(keyword)) || + p.Tags.Any(t => t.DisplayName.Contains(keyword) || t.NormalizedName.Contains(keyword)) || + p.PostCategory.Any(pc => pc.Category != null && (pc.Category.DisplayName.Contains(keyword) || pc.Category.Slug.Contains(keyword)))); } - else + + return query; + } + + private static IQueryable ApplyFacetFilters(IQueryable query, SearchPostQuery request) + { + if (!string.IsNullOrWhiteSpace(request.CategorySlug)) { - var word = words[0]; - query = query.Where(p => - p.Title.Contains(word) || - p.Tags.Any(t => t.DisplayName.Contains(word))); + var categorySlug = request.CategorySlug.Trim().ToLower(); + query = query.Where(p => p.PostCategory.Any(pc => pc.Category.Slug == categorySlug)); } - var results = await query - .SelectToDigest() - .ToListAsync(ct); + if (!string.IsNullOrWhiteSpace(request.Tag)) + { + var tag = request.Tag.Trim().ToLower(); + query = query.Where(p => p.Tags.Any(t => t.NormalizedName == tag)); + } + + if (!string.IsNullOrWhiteSpace(request.LanguageCode)) + { + var languageCode = request.LanguageCode.Trim().ToLower(); + query = query.Where(p => p.ContentLanguageCode == languageCode); + } + + if (request.StartDateUtc.HasValue) + { + var startDateUtc = request.StartDateUtc.Value.Date; + query = query.Where(p => p.PubDateUtc >= startDateUtc); + } - return results; + if (request.EndDateUtc.HasValue) + { + var endDateUtc = request.EndDateUtc.Value.Date.AddDays(1); + query = query.Where(p => p.PubDateUtc < endDateUtc); + } + + return query; } + + private static IOrderedQueryable ApplySorting(IQueryable query, SearchPostSort sort) => + sort switch + { + SearchPostSort.Oldest => query + .OrderBy(p => p.PubDateUtc) + .ThenBy(p => p.Title), + SearchPostSort.TitleAscending => query + .OrderBy(p => p.Title) + .ThenByDescending(p => p.PubDateUtc), + SearchPostSort.TitleDescending => query + .OrderByDescending(p => p.Title) + .ThenByDescending(p => p.PubDateUtc), + _ => query + .OrderByDescending(p => p.PubDateUtc) + .ThenBy(p => p.Title) + }; } diff --git a/src/Moonglade.Web/Pages/Search.cshtml b/src/Moonglade.Web/Pages/Search.cshtml index d144d4f4f..6f4641995 100644 --- a/src/Moonglade.Web/Pages/Search.cshtml +++ b/src/Moonglade.Web/Pages/Search.cshtml @@ -1,7 +1,13 @@ -@page "/search" +@page "/search" +@using Microsoft.AspNetCore.Builder @using Microsoft.AspNetCore.Mvc.TagHelpers +@using Microsoft.Extensions.Options @using Moonglade.Data.DTO +@using Moonglade.Data.Entities +@using Moonglade.Features.Category @using Moonglade.Features.Post +@using Moonglade.Features.Tag +@inject IOptions LocOptions @section meta { @@ -10,13 +16,81 @@ @{ ViewBag.BodyClass = "body-search-list bg-gray-1"; HttpContext.Response.Headers.Add("X-Robots-Tag", "noindex, nofollow"); + + var cultureItems = LocOptions.Value.SupportedUICultures? + .Select(c => new { Value = c.Name.ToLower(), c.NativeName }) + .ToList() ?? []; }

@SharedLocalizer["Search Result"]

-@if (!Posts.Any()) +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Clear + +
+
+
+ +@if (!Posts.Items.Any()) {
@SharedLocalizer["No Matching Result"] @@ -24,26 +98,157 @@ } else { - foreach (var item in Posts.OrderByDescending(s => s.PubDateUtc)) +
+ @Posts.TotalItemCount result@(Posts.TotalItemCount == 1 ? string.Empty : "s") +
+ + foreach (var item in Posts.Items) { } + + @if (Posts.PageCount > 1) + { +
+
    + @if (Posts.PageNumber > 1) + { +
  • + +
  • + } + + @foreach (var pageNumber in GetPageNumbers()) + { + if (pageNumber == Posts.PageNumber) + { +
  • + @pageNumber +
  • + } + else + { +
  • + @pageNumber +
  • + } + } + + @if (Posts.PageNumber < Posts.PageCount) + { +
  • + +
  • + } +
+
+ } } @functions { - public List Posts { get; set; } = new(); + public PagedResult Posts { get; set; } = new([], 1, 1, 0); + public List Categories { get; set; } = []; + public List Tags { get; set; } = []; public string SearchTerm { get; set; } = string.Empty; + public string Sort { get; set; } = "newest"; + public string Category { get; set; } + public string Tag { get; set; } + public string Language { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } - public async Task OnGetAsync(string term) + public async Task OnGetAsync( + string term, + int p = 1, + string sort = "newest", + string category = null, + string tag = null, + string language = null, + DateTime? startDate = null, + DateTime? endDate = null) { if (string.IsNullOrWhiteSpace(term)) return RedirectToPage("Index"); - ViewData["TitlePrefix"] = term; - SearchTerm = term; + SearchTerm = term.Trim(); + Sort = NormalizeSort(sort); + Category = NormalizeSlug(category); + Tag = NormalizeSlug(tag); + Language = NormalizeSlug(language); + StartDate = startDate; + EndDate = endDate; + ViewData["TitlePrefix"] = SearchTerm; + + Categories = await QueryMediator.QueryAsync(new ListCategoriesQuery()); + Tags = await QueryMediator.QueryAsync(new ListTagsQuery()); + + var pageNumber = Math.Max(1, p); + var pageSize = BlogConfig.ContentSettings.PostListPageSize; + var result = await QueryMediator.QueryAsync(new SearchPostQuery( + SearchTerm, + pageSize, + pageNumber, + Category, + Tag, + Language, + StartDate, + EndDate, + ParseSort(Sort))); - var posts = await QueryMediator.QueryAsync(new SearchPostQuery(term)); - Posts = posts; + Posts = new PagedResult(result.Posts, pageNumber, pageSize, result.TotalRows); + if (Posts.PageCount > 0 && pageNumber > Posts.PageCount) + { + return Redirect(BuildPageUrl(Posts.PageCount)); + } return Page(); } -} \ No newline at end of file + + private string BuildPageUrl(int page) => Url.Page("/Search", new + { + term = SearchTerm, + p = page, + sort = Sort, + category = Category, + tag = Tag, + language = Language, + startDate = FormatDate(StartDate), + endDate = FormatDate(EndDate) + }); + + private IEnumerable GetPageNumbers() + { + var maxPages = BlogConfig.ContentSettings.MaximumPageNumbersToDisplay; + var firstPage = Math.Max(1, Posts.PageNumber - maxPages / 2); + var lastPage = Math.Min(Posts.PageCount, firstPage + maxPages - 1); + + if (lastPage - firstPage + 1 < maxPages) + { + firstPage = Math.Max(1, lastPage - maxPages + 1); + } + + return Enumerable.Range(firstPage, lastPage - firstPage + 1); + } + + private static string FormatDate(DateTime? date) => date?.ToString("yyyy-MM-dd"); + + private static string NormalizeSlug(string value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLower(); + + private static string NormalizeSort(string sort) => + sort?.Trim().ToLower() switch + { + "oldest" => "oldest", + "title-asc" => "title-asc", + "title-desc" => "title-desc", + _ => "newest" + }; + + private static SearchPostSort ParseSort(string sort) => + sort switch + { + "oldest" => SearchPostSort.Oldest, + "title-asc" => SearchPostSort.TitleAscending, + "title-desc" => SearchPostSort.TitleDescending, + _ => SearchPostSort.Newest + }; +} diff --git a/src/Moonglade.Web/Pages/Shared/_PostListEntry.cshtml b/src/Moonglade.Web/Pages/Shared/_PostListEntry.cshtml index 27e0e3317..1dbe9a4ae 100644 --- a/src/Moonglade.Web/Pages/Shared/_PostListEntry.cshtml +++ b/src/Moonglade.Web/Pages/Shared/_PostListEntry.cshtml @@ -1,4 +1,5 @@ @using System.Text.RegularExpressions +@using System.Text.Encodings.Web @using Moonglade.Data.DTO @model PostDigest @@ -10,11 +11,21 @@ private static string HighlightKeywords(string text, string searchTerm) { if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(searchTerm)) - return text; + return HtmlEncoder.Default.Encode(text); - var pattern = Regex.Escape(searchTerm); - var regex = new Regex(pattern, RegexOptions.IgnoreCase); - return regex.Replace(text, match => $"{match.Value}"); + var encodedText = HtmlEncoder.Default.Encode(text); + var terms = Regex.Replace(searchTerm.Trim(), @"\s+", " ") + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(HtmlEncoder.Default.Encode) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderByDescending(term => term.Length) + .Select(Regex.Escape) + .ToArray(); + + if (terms.Length == 0) return encodedText; + + var regex = new Regex(string.Join('|', terms), RegexOptions.IgnoreCase); + return regex.Replace(encodedText, match => $"{match.Value}"); } } @@ -65,4 +76,4 @@
} - \ No newline at end of file + diff --git a/src/Tests/Moonglade.Features.Tests/PostQueryTests.cs b/src/Tests/Moonglade.Features.Tests/PostQueryTests.cs index 560611311..52366e456 100644 --- a/src/Tests/Moonglade.Features.Tests/PostQueryTests.cs +++ b/src/Tests/Moonglade.Features.Tests/PostQueryTests.cs @@ -81,10 +81,10 @@ public async Task SearchPostQuery_SingleWordSearchesTitleOrTag() var result = await handler.HandleAsync(new SearchPostQuery("Azure"), TestContext.Current.CancellationToken); - Assert.Equal(2, result.Count); - Assert.Contains(result, p => p.Slug == "title-match"); - Assert.Contains(result, p => p.Slug == "tag-match"); - Assert.DoesNotContain(result, p => p.Slug == "draft"); + Assert.Equal(2, result.TotalRows); + Assert.Contains(result.Posts, p => p.Slug == "title-match"); + Assert.Contains(result.Posts, p => p.Slug == "tag-match"); + Assert.DoesNotContain(result.Posts, p => p.Slug == "draft"); } [Fact] @@ -100,8 +100,74 @@ public async Task SearchPostQuery_MultipleWordsRequiresAllWordsInTitle() var result = await handler.HandleAsync(new SearchPostQuery("Azure Functions"), TestContext.Current.CancellationToken); - Assert.Single(result); - Assert.Equal("match", result[0].Slug); + Assert.Single(result.Posts); + Assert.Equal("match", result.Posts[0].Slug); + } + + [Fact] + public async Task SearchPostQuery_PaginatesAndSorts() + { + using var db = CreateDbContext(); + db.Post.AddRange( + CreatePost(Guid.NewGuid(), "b", PostStatus.Published, DateTime.UtcNow.AddDays(-1), title: "Azure B"), + CreatePost(Guid.NewGuid(), "a", PostStatus.Published, DateTime.UtcNow.AddDays(-2), title: "Azure A"), + CreatePost(Guid.NewGuid(), "c", PostStatus.Published, DateTime.UtcNow, title: "Azure C")); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + + var handler = new SearchPostQueryHandler(db); + + var result = await handler.HandleAsync( + new SearchPostQuery("Azure", PageSize: 2, PageIndex: 1, Sort: SearchPostSort.TitleAscending), + TestContext.Current.CancellationToken); + + Assert.Equal(3, result.TotalRows); + Assert.Collection(result.Posts, + p => Assert.Equal("a", p.Slug), + p => Assert.Equal("b", p.Slug)); + } + + [Fact] + public async Task SearchPostQuery_FiltersByCategoryTagLanguageAndDate() + { + using var db = CreateDbContext(); + var category = new CategoryEntity { Id = Guid.NewGuid(), Slug = "cloud", DisplayName = "Cloud" }; + var tag = new TagEntity { DisplayName = "Azure", NormalizedName = "azure" }; + var matchingPost = CreatePost( + Guid.NewGuid(), + "match", + PostStatus.Published, + new DateTime(2024, 4, 10, 0, 0, 0, DateTimeKind.Utc), + title: "Azure Guide", + languageCode: "en-us"); + matchingPost.Tags.Add(tag); + matchingPost.PostCategory.Add(new PostCategoryEntity + { + Post = matchingPost, + PostId = matchingPost.Id, + Category = category, + CategoryId = category.Id + }); + db.Category.Add(category); + db.Post.AddRange( + matchingPost, + CreatePost(Guid.NewGuid(), "wrong-language", PostStatus.Published, new DateTime(2024, 4, 10, 0, 0, 0, DateTimeKind.Utc), title: "Azure Guide", languageCode: "de-de"), + CreatePost(Guid.NewGuid(), "wrong-date", PostStatus.Published, new DateTime(2024, 3, 1, 0, 0, 0, DateTimeKind.Utc), title: "Azure Guide")); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + + var handler = new SearchPostQueryHandler(db); + + var result = await handler.HandleAsync( + new SearchPostQuery( + "Azure", + CategorySlug: "cloud", + Tag: "azure", + LanguageCode: "en-us", + StartDateUtc: new DateTime(2024, 4, 1, 0, 0, 0, DateTimeKind.Utc), + EndDateUtc: new DateTime(2024, 4, 30, 0, 0, 0, DateTimeKind.Utc)), + TestContext.Current.CancellationToken); + + Assert.Single(result.Posts); + Assert.Equal("match", result.Posts[0].Slug); } [Fact] @@ -239,7 +305,8 @@ private static PostEntity CreatePost( bool isFeatured = false, string? title = null, string? routeLink = null, - string? contentAbstract = null) + string? contentAbstract = null, + string languageCode = "en-us") { return new PostEntity { @@ -252,7 +319,7 @@ private static PostEntity CreatePost( CreateTimeUtc = DateTime.UtcNow.AddDays(-3), LastModifiedUtc = DateTime.UtcNow.AddDays(-2), ContentAbstract = contentAbstract ?? "Abstract", - ContentLanguageCode = "en-us", + ContentLanguageCode = languageCode, IsFeedIncluded = true, PubDateUtc = pubDateUtc, PostStatus = status, From 56f2e6071e10bccd6e560c431cc2cb45ae6edc33 Mon Sep 17 00:00:00 2001 From: Edi Wang Date: Mon, 8 Jun 2026 19:54:03 +0800 Subject: [PATCH 06/20] Update FEATURE-ROADMAP.md --- FEATURE-ROADMAP.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/FEATURE-ROADMAP.md b/FEATURE-ROADMAP.md index 53eb5660a..b317625d2 100644 --- a/FEATURE-ROADMAP.md +++ b/FEATURE-ROADMAP.md @@ -29,11 +29,13 @@ Moonglade is already a mature personal blogging platform for developers. Its exi The current search experience mainly covers post titles and tags. It should become a more complete site search experience. -- [ ] Search across titles, abstracts, keywords, categories, and tags without scanning full post bodies. -- [ ] Support pagination, sorting, and result highlighting. -- [ ] Support filters by category, tag, language, and publish date. -- [ ] Keep the implementation compatible with SQL Server and PostgreSQL. -- [ ] Add matching Features and Web tests. +- [x] Search across titles, abstracts, keywords, categories, and tags without scanning full post bodies. +- [x] Support pagination, sorting, and result highlighting. +- [x] Support filters by category, tag, language, and publish date. +- [x] Keep the implementation compatible with SQL Server and PostgreSQL. +- [x] Add matching Features tests for search query behavior. +- [ ] Add optional Web tests for search page rendering and query string state. +- [x] Keep full-body search out of scope to avoid the complexity and performance cost of full-text search. ### Editing Experience @@ -223,7 +225,7 @@ This is a larger architectural change and should be deferred until higher-priori Goal: improve daily use and create a stronger foundation for future features. -- [ ] Enhance site search. +- [x] Enhance site search. - [ ] Expand complete export coverage. - [ ] Add an admin system diagnostics page. - [ ] Clean up hard-coded localization text. @@ -231,7 +233,7 @@ Goal: improve daily use and create a stronger foundation for future features. Acceptance criteria: -- [ ] Search results are more complete and support pagination, filtering, and sorting. +- [x] Search results are more complete and support pagination, filtering, and sorting. - [ ] Exported data covers the main business entities. - [ ] Admin users can inspect the health of critical dependencies. - [ ] New or changed UI text is synchronized to non-English resource files. From 05aa4072a1c8be23296e9b2e62c54dfd5125873e Mon Sep 17 00:00:00 2001 From: Edi Wang Date: Mon, 8 Jun 2026 19:59:52 +0800 Subject: [PATCH 07/20] Localize search filters and add translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hard-coded labels and option text in Search.cshtml with SharedLocalizer lookups (Sort, sort options, All categories, All tags, All languages, Published from/to, Clear and the result(s) message). Add corresponding translations for those keys in Program.resx for de-DE, ja-JP, zh-Hans and zh-Hant. No functional changes — this prepares the Search page for localization and uses localized plural formatting for the results count. --- src/Moonglade.Web/Pages/Search.cshtml | 24 ++++++------- .../Resources/Program.de-DE.resx | 36 +++++++++++++++++++ .../Resources/Program.ja-JP.resx | 36 +++++++++++++++++++ .../Resources/Program.zh-Hans.resx | 36 +++++++++++++++++++ .../Resources/Program.zh-Hant.resx | 36 +++++++++++++++++++ 5 files changed, 156 insertions(+), 12 deletions(-) diff --git a/src/Moonglade.Web/Pages/Search.cshtml b/src/Moonglade.Web/Pages/Search.cshtml index 6f4641995..ae8d530bd 100644 --- a/src/Moonglade.Web/Pages/Search.cshtml +++ b/src/Moonglade.Web/Pages/Search.cshtml @@ -33,18 +33,18 @@
- +
- + @foreach (var tag in Tags.OrderBy(t => t.DisplayName)) { @@ -64,7 +64,7 @@
- +
- +
@@ -84,7 +84,7 @@ @SharedLocalizer["Filter"] - Clear + @SharedLocalizer["Clear"]
@@ -99,7 +99,7 @@ else {
- @Posts.TotalItemCount result@(Posts.TotalItemCount == 1 ? string.Empty : "s") + @SharedLocalizer[Posts.TotalItemCount == 1 ? "{0} result" : "{0} results", Posts.TotalItemCount]
foreach (var item in Posts.Items) diff --git a/src/Moonglade.Web/Resources/Program.de-DE.resx b/src/Moonglade.Web/Resources/Program.de-DE.resx index 4366980a6..c5d9022eb 100644 --- a/src/Moonglade.Web/Resources/Program.de-DE.resx +++ b/src/Moonglade.Web/Resources/Program.de-DE.resx @@ -1218,4 +1218,40 @@ Dieser Beitrag enthält KI-generierte oder KI-unterstützte Bearbeitungen. + + Sortieren + + + Neueste zuerst + + + Älteste zuerst + + + Titel A-Z + + + Titel Z-A + + + Alle Kategorien + + + Alle Sprachen + + + Veröffentlicht ab + + + Veröffentlicht bis + + + Zurücksetzen + + + {0} Ergebnis + + + {0} Ergebnisse + diff --git a/src/Moonglade.Web/Resources/Program.ja-JP.resx b/src/Moonglade.Web/Resources/Program.ja-JP.resx index 928b2a687..0a5dc0aae 100644 --- a/src/Moonglade.Web/Resources/Program.ja-JP.resx +++ b/src/Moonglade.Web/Resources/Program.ja-JP.resx @@ -1218,4 +1218,40 @@ この投稿には、AI生成またはAI支援による編集が含まれています。 + + 並べ替え + + + 新しい順 + + + 古い順 + + + タイトル A-Z + + + タイトル Z-A + + + すべてのカテゴリ + + + すべての言語 + + + 公開日(開始) + + + 公開日(終了) + + + クリア + + + {0} 件の結果 + + + {0} 件の結果 + diff --git a/src/Moonglade.Web/Resources/Program.zh-Hans.resx b/src/Moonglade.Web/Resources/Program.zh-Hans.resx index 96da65c6d..40e75cd63 100644 --- a/src/Moonglade.Web/Resources/Program.zh-Hans.resx +++ b/src/Moonglade.Web/Resources/Program.zh-Hans.resx @@ -1218,4 +1218,40 @@ 本文包含 AI 创作或辅助润色的部分。 + + 排序 + + + 最新优先 + + + 最早优先 + + + 标题 A-Z + + + 标题 Z-A + + + 所有分类 + + + 所有语言 + + + 发布起始日期 + + + 发布结束日期 + + + 清除 + + + {0} 个结果 + + + {0} 个结果 + diff --git a/src/Moonglade.Web/Resources/Program.zh-Hant.resx b/src/Moonglade.Web/Resources/Program.zh-Hant.resx index f991fd082..a923ba7a2 100644 --- a/src/Moonglade.Web/Resources/Program.zh-Hant.resx +++ b/src/Moonglade.Web/Resources/Program.zh-Hant.resx @@ -1218,4 +1218,40 @@ 本文包含 AI 創作或輔助潤色的部分。 + + 排序 + + + 最新優先 + + + 最早優先 + + + 標題 A-Z + + + 標題 Z-A + + + 所有分類 + + + 所有語言 + + + 發布起始日期 + + + 發布結束日期 + + + 清除 + + + {0} 個結果 + + + {0} 個結果 + From 07923b7ba25ac2e7c6b3e69b594ec0d6437c2979 Mon Sep 17 00:00:00 2001 From: Edi Wang Date: Mon, 8 Jun 2026 20:30:04 +0800 Subject: [PATCH 08/20] Add auto-save, localization and lastModified support Expose post LastModifiedUtc in PostOperationResult and controller responses; include LastModifiedUtc when saving posts. Add auto-save feature in the post editor: UI toggle, CSS styling, persistent setting, interval timer, snapshot/validation logic and background save flow. Introduce localized strings and wire getLocalizedString into editor, form, schedule and slug mixins; update EditPost view to use SharedLocalizer and embed localized strings. Update multiple .resx resource files (de, ja, zh-Hans, zh-Hant) with new keys. Minor refactors: extract request/snapshot helpers and savePost method, and replace hardcoded messages with localized messages. --- .../Commands/PostManagementCommands.cs | 10 +- .../Controllers/PostController.cs | 6 +- src/Moonglade.Web/Pages/Admin/EditPost.cshtml | 60 ++++- .../Resources/Program.de-DE.resx | 57 ++++ .../Resources/Program.ja-JP.resx | 57 ++++ .../Resources/Program.zh-Hans.resx | 57 ++++ .../Resources/Program.zh-Hant.resx | 57 ++++ src/Moonglade.Web/wwwroot/css/admin.css | 16 ++ .../wwwroot/js/app/admin.editpost.form.mjs | 4 +- .../wwwroot/js/app/admin.editpost.mjs | 244 +++++++++++++++--- .../js/app/admin.editpost.schedule.mjs | 5 +- .../wwwroot/js/app/admin.editpost.slug.mjs | 8 +- 12 files changed, 517 insertions(+), 64 deletions(-) diff --git a/src/Moonglade.Web/Commands/PostManagementCommands.cs b/src/Moonglade.Web/Commands/PostManagementCommands.cs index b6025bb1a..23b8ee3cd 100644 --- a/src/Moonglade.Web/Commands/PostManagementCommands.cs +++ b/src/Moonglade.Web/Commands/PostManagementCommands.cs @@ -14,11 +14,11 @@ public record PostOperationContext( string UserAgent, string RootUrl); -public record PostOperationResult(bool Succeeded, Guid PostId, string ErrorMessage) +public record PostOperationResult(bool Succeeded, Guid PostId, DateTime? LastModifiedUtc, string ErrorMessage) { - public static PostOperationResult Success(Guid postId) => new(true, postId, string.Empty); + public static PostOperationResult Success(Guid postId, DateTime? lastModifiedUtc) => new(true, postId, lastModifiedUtc, string.Empty); - public static PostOperationResult Conflict(string errorMessage) => new(false, Guid.Empty, errorMessage); + public static PostOperationResult Conflict(string errorMessage) => new(false, Guid.Empty, null, errorMessage); } public record SavePostCommand(PostEditModel Payload, PostOperationContext Context) : ICommand; @@ -67,7 +67,7 @@ await PostActivityLogger.LogAsync( ProcessPublishedPost(model.LastModifiedUtc, postEntity, request.Context); } - return PostOperationResult.Success(postEntity.Id); + return PostOperationResult.Success(postEntity.Id, postEntity.LastModifiedUtc); } catch (Exception ex) { @@ -237,4 +237,4 @@ await commandMediator.SendAsync(new CreateActivityLogCommand( context.IpAddress, context.UserAgent), ct); } -} \ No newline at end of file +} diff --git a/src/Moonglade.Web/Controllers/PostController.cs b/src/Moonglade.Web/Controllers/PostController.cs index 2f524a007..6b11b1c9f 100644 --- a/src/Moonglade.Web/Controllers/PostController.cs +++ b/src/Moonglade.Web/Controllers/PostController.cs @@ -43,7 +43,11 @@ public async Task CreateOrEdit(PostEditModel model) return Conflict(result.ErrorMessage); } - return Ok(new { result.PostId }); + return Ok(new + { + result.PostId, + LastModifiedUtc = result.LastModifiedUtc?.ToString("u") + }); } [TypeFilter(typeof(ClearBlogCache), Arguments = diff --git a/src/Moonglade.Web/Pages/Admin/EditPost.cshtml b/src/Moonglade.Web/Pages/Admin/EditPost.cshtml index 697e452b0..b8f408d55 100644 --- a/src/Moonglade.Web/Pages/Admin/EditPost.cshtml +++ b/src/Moonglade.Web/Pages/Admin/EditPost.cshtml @@ -1,7 +1,7 @@ @page "/admin/post/edit/{id:guid?}" @{ - ViewBag.Title = "Edit Post"; + ViewBag.Title = SharedLocalizer["Edit Post"]; ViewBag.XData = "postEditor"; ViewBag.HideAdminSidebar = true; ViewBag.AdminBackUrl = "javascript:history.go(-1);"; @@ -12,15 +12,49 @@ } +@section navbarActions { + +} + @section head { } @Html.AntiForgeryToken() + +
- Loading post data... + @SharedLocalizer["Loading post data..."]
@@ -43,7 +77,7 @@
- Drag & Drop / Paste image here to upload. + @SharedLocalizer["Drag & Drop / Paste image here to upload."]
@@ -71,7 +105,7 @@ Modify + class="btn btn-warning btn-sm">@SharedLocalizer["Modify"]
@@ -122,21 +156,21 @@ x-model="formData.enableComment" class="form-check-input" id="enableComment"> - +
- +
- +
- +
@@ -234,7 +268,7 @@ diff --git a/src/Moonglade.Web/Pages/Admin/EditPage.cshtml b/src/Moonglade.Web/Pages/Admin/EditPage.cshtml index 867ab954b..13b4aec29 100644 --- a/src/Moonglade.Web/Pages/Admin/EditPage.cshtml +++ b/src/Moonglade.Web/Pages/Admin/EditPage.cshtml @@ -1,7 +1,7 @@ @page "/admin/page/edit/{id:guid?}" @using Microsoft.AspNetCore.Mvc.TagHelpers @{ - ViewBag.Title = "Edit Page"; + ViewBag.Title = SharedLocalizer["Edit Page"]; ViewBag.XData = "pageEditor"; ViewBag.HideAdminSidebar = true; ViewBag.AdminBackUrl = "/admin/page"; @@ -12,6 +12,10 @@ } + + @section navbarActions {
@@ -36,7 +40,7 @@
@@ -46,7 +50,7 @@
@@ -60,7 +64,7 @@ href="#rawhtmlcontent-editor-box" role="tab" aria-controls="html" - aria-selected="true">HTML + aria-selected="true">@SharedLocalizer["HTML"] @@ -115,7 +119,7 @@ x-bind:disabled="isSaving" x-on:click="isPreview = false"> @SharedLocalizer["Save"] - Saving... + @SharedLocalizer["Saving..."] +
@@ -141,26 +145,26 @@ x-model="formData.hideSidebar" class="form-check-input" id="hideSidebar"> - +
- +
- +
- \ No newline at end of file + diff --git a/src/Moonglade.Web/Pages/Admin/LocalAccount.cshtml b/src/Moonglade.Web/Pages/Admin/LocalAccount.cshtml index af59eeaf5..9491968ec 100644 --- a/src/Moonglade.Web/Pages/Admin/LocalAccount.cshtml +++ b/src/Moonglade.Web/Pages/Admin/LocalAccount.cshtml @@ -1,6 +1,6 @@ @page "/admin/account" @{ - ViewBag.Title = "Account"; + ViewBag.Title = SharedLocalizer["Account"]; ViewBag.XData = "accountManager"; } @@ -24,7 +24,10 @@ }
- Local account is not secure. It's recommended to use Microsoft Entra ID for authentication. Click here to configure. + @Html.Raw(string.Format( + SharedLocalizer["Local account is not secure. It's recommended to use {0} for authentication. Click {1} to configure."].Value, + "Microsoft Entra ID", + "here"))
- \ No newline at end of file + diff --git a/src/Moonglade.Web/Pages/Admin/Settings/Advanced.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/Advanced.cshtml index fe4988650..c79f17c8c 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/Advanced.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/Advanced.cshtml @@ -59,12 +59,12 @@
- +
@if (string.IsNullOrWhiteSpace(Configuration["IndexNow:ApiKey"])) { - Not configured + @SharedLocalizer["Not configured"] } else { @@ -196,9 +196,9 @@
- * Global script that will be injected into every page's head section.
+ @Html.Raw(string.Format(SharedLocalizer["* Global script that will be injected into every page's {0} section."].Value, "head"))
- * Please use valid <script>...</script> code. + @Html.Raw(string.Format(SharedLocalizer["* Please use valid {0} code."].Value, "<script>...</script>"))
@@ -211,9 +211,9 @@
- * Global script that will be injected into every page's footer, before the ending of body tag. Typically used for third party analytics services.
+ @Html.Raw(string.Format(SharedLocalizer["* Global script that will be injected into every page's footer, before the ending of {0} tag. Typically used for third party analytics services."].Value, "body"))
- * Please use valid <script>...</script> code. + @Html.Raw(string.Format(SharedLocalizer["* Please use valid {0} code."].Value, "<script>...</script>"))
diff --git a/src/Moonglade.Web/Pages/Admin/Settings/Appearance.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/Appearance.cshtml index 35500fd28..314d6e144 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/Appearance.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/Appearance.cshtml @@ -8,7 +8,8 @@ } @section head { diff --git a/src/Moonglade.Web/Pages/Admin/Settings/Comment.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/Comment.cshtml index d2dc68354..bad00b65c 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/Comment.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/Comment.cshtml @@ -172,24 +172,26 @@
@if (ModeratorOptions.Value.Provider.ToLowerInvariant() == "local") { - Using local provider + @SharedLocalizer["Using local provider"] } else if (ModeratorOptions.Value.Provider.ToLowerInvariant() == "remote") { if (!string.IsNullOrWhiteSpace(ModeratorOptions.Value.ApiEndpoint)) { - Using Azure provider at @ModeratorOptions.Value.ApiEndpoint + @Html.Raw(string.Format( + SharedLocalizer["Using Azure provider at {0}"].Value, + $"{System.Net.WebUtility.HtmlEncode(ModeratorOptions.Value.ApiEndpoint)}")) } else { - - Please follow instruction to setup content security API - + @Html.Raw(string.Format( + SharedLocalizer["Please follow {0} to setup content security API"].Value, + "instruction")) } } else { - Unknown provider, this feature may not be working. + @SharedLocalizer["Unknown provider, this feature may not be working."] }
@SharedLocalizer["Word filter only applies when comment review is disabled."]
diff --git a/src/Moonglade.Web/Pages/Admin/Settings/General.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/General.cshtml index 8ba28a1c1..519e2aecc 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/General.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/General.cshtml @@ -27,7 +27,11 @@ } @section admintoolbar { @@ -83,7 +87,7 @@
- +
diff --git a/src/Moonglade.Web/Pages/Admin/Settings/Image.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/Image.cshtml index cf742f33d..223d29e2f 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/Image.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/Image.cshtml @@ -41,7 +41,7 @@
-
e.g. https://cdn.edi.wang/ediwang-images
+
@SharedLocalizer["e.g. https://cdn.edi.wang/ediwang-images"]
@@ -49,7 +49,11 @@
-
* Recommend to use Azure Front Door and CDN
+
+ @Html.Raw(string.Format( + SharedLocalizer["* Recommend to use {0}"].Value, + "Azure Front Door and CDN")) +

@@ -126,7 +130,7 @@

- +
@SharedLocalizer["Transparency value (0-255)."]
diff --git a/src/Moonglade.Web/Pages/Admin/Settings/Notification.cshtml b/src/Moonglade.Web/Pages/Admin/Settings/Notification.cshtml index 3b056948a..3c8acf0a6 100644 --- a/src/Moonglade.Web/Pages/Admin/Settings/Notification.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Settings/Notification.cshtml @@ -5,7 +5,8 @@ } @{ @@ -27,7 +28,9 @@ string.IsNullOrWhiteSpace(Configuration["Email:ApiKey"])) {
- Please follow instructions to setup email API. + @Html.Raw(string.Format( + SharedLocalizer["Please follow {0} to setup email API."].Value, + "instructions"))
} else @@ -37,7 +40,7 @@
- +
@@ -66,7 +69,7 @@
diff --git a/src/Moonglade.Web/Pages/Admin/Widgets.cshtml b/src/Moonglade.Web/Pages/Admin/Widgets.cshtml index 0cb3605be..1a590804b 100644 --- a/src/Moonglade.Web/Pages/Admin/Widgets.cshtml +++ b/src/Moonglade.Web/Pages/Admin/Widgets.cshtml @@ -19,7 +19,17 @@ data-image-url-required="@SharedLocalizer["Image URL is required"]" data-text-url-required="@SharedLocalizer["Button text and URL are required"]" data-max-buttons="@SharedLocalizer["You can add up to 3 buttons"]" - data-remove-button="@SharedLocalizer["Are you sure you want to remove this button?"]"> + data-remove-button="@SharedLocalizer["Are you sure you want to remove this button?"]" + data-display-order-range="@SharedLocalizer["Display Order must be between -30 and 999."]" + data-unnamed-link="@SharedLocalizer["Unnamed Link"]" + data-unnamed-button="@SharedLocalizer["Unnamed Button"]" + data-open-in-new-tab="@SharedLocalizer["Open in new tab"]" + data-open-in-same-tab="@SharedLocalizer["Opens in same tab"]" + data-add-new-link="@SharedLocalizer["Add New Link"]" + data-edit-link="@SharedLocalizer["Edit Link"]" + data-add-new-button="@SharedLocalizer["Add New Button"]" + data-edit-button="@SharedLocalizer["Edit Button"]" + data-button="@SharedLocalizer["Button"]"> @section admintoolbar { @@ -52,7 +62,7 @@
- Disabled + @SharedLocalizer["Disabled"]

@@ -60,7 +70,7 @@