From b5614ff59bb2aebba101aec19e300be5a1cbc2a1 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Thu, 1 Oct 2026 22:20:56 +0800 Subject: [PATCH] feat(m3): search and export - Pidgin lexer: words, escaped phrases (strict closing), parens - Full-Pidgin token-level parser, strict: malformed input throws SearchSyntaxException (BREAKING: lenient degradation removed from the plan; M7 must surface the error, M8 USAGE.md documents strict behavior) - AST with reference-equality collection terms; structural comparison lives in the test-side SearchNodeComparer (YYC MARK note records the record-equality pitfall) - SQL compiler: parameterized predicate over cards alias, LIKE escaping, COLLATE NOCASE exact matches, tag EXISTS / column IN subqueries - Markdown exporter: ResX-driven templates, per-language export time AND card time patterns, kebab i18n file names, table cell escaping - New RgbColor value type (byte components, strict #RRGGBB parse, canonical lowercase text) wired through TagModel and repository - 57 new tests (grammar branches, strict errors, SQL end-to-end, en/zh export snapshots, color conversions) --- YKanBan.Tests/Export/MarkdownExporterTests.cs | 171 ++++++++++++++ YKanBan.Tests/Search/SearchNodeComparer.cs | 54 +++++ .../Search/SearchQueryParserTests.cs | 222 ++++++++++++++++++ .../Search/SearchSqlCompilerTests.cs | 173 ++++++++++++++ YKanBan/Assets/Locales/Resources.resx | 30 +++ YKanBan/Assets/Locales/Resources.zh-Hans.resx | 30 +++ YKanBan/Export/MarkdownExporter.cs | 117 +++++++++ YKanBan/Search/SearchLexer.cs | 73 ++++++ YKanBan/Search/SearchNodes.cs | 45 ++++ YKanBan/Search/SearchQueryParser.cs | 194 +++++++++++++++ YKanBan/Search/SearchSqlCompiler.cs | 86 +++++++ 11 files changed, 1195 insertions(+) create mode 100644 YKanBan.Tests/Export/MarkdownExporterTests.cs create mode 100644 YKanBan.Tests/Search/SearchNodeComparer.cs create mode 100644 YKanBan.Tests/Search/SearchQueryParserTests.cs create mode 100644 YKanBan.Tests/Search/SearchSqlCompilerTests.cs create mode 100644 YKanBan/Export/MarkdownExporter.cs create mode 100644 YKanBan/Search/SearchLexer.cs create mode 100644 YKanBan/Search/SearchNodes.cs create mode 100644 YKanBan/Search/SearchQueryParser.cs create mode 100644 YKanBan/Search/SearchSqlCompiler.cs diff --git a/YKanBan.Tests/Export/MarkdownExporterTests.cs b/YKanBan.Tests/Export/MarkdownExporterTests.cs new file mode 100644 index 0000000..8cb5a8b --- /dev/null +++ b/YKanBan.Tests/Export/MarkdownExporterTests.cs @@ -0,0 +1,171 @@ +using System.Globalization; +using YKanBan.Export; +using YKanBan.Models; + +namespace YKanBan.Tests.Export; + +/// +/// Tests for the Markdown exporter. Mutates the global Resources.Culture, +/// therefore marked DoNotParallelize. Asserts real localized output for +/// both supported languages. +/// +[TestClass] +[DoNotParallelize] +public class MarkdownExporterTests +{ + private static readonly DateTimeOffset ExportedAt = new(2026, 9, 30, 14, 25, 37, TimeSpan.Zero); + + /// A local wall-clock moment, matching the exporter's conversion path. + private static DateTimeOffset Local(int year, int month, int day, int hour, int minute) => + new DateTimeOffset(new DateTime(year, month, day, hour, minute, 0)).ToLocalTime(); + + /// Unix seconds for a local wall-clock time. + private static long UnixTimeOf(int year, int month, int day, int hour, int minute) => + Local(year, month, day, hour, minute).ToUnixTimeSeconds(); + + /// Builds the board fixture: one working column with two cards, one empty column, two tags. + private static (IReadOnlyList Columns, IReadOnlyList Cards, IReadOnlyList<(TagModel, long)> Tags) CreateBoard() + { + long created = UnixTimeOf(2026, 9, 1, 10, 0); + long updated = UnixTimeOf(2026, 9, 28, 18, 30); + + var columns = new List + { + new(1, "In Progress", "The work column", 100, 100), + new(2, "Empty", string.Empty, 100, 100), + }; + var bug = new TagModel(1, "bug", RgbColor.Parse("#ff0000"), string.Empty); + var ui = new TagModel(2, "ui", RgbColor.Parse("#00ff00"), "Interface work"); + var cards = new List + { + new(61, 1, "Fix login", "Body text", created, updated, [bug, ui]), + new(62, 1, string.Empty, string.Empty, created, updated, []), + }; + var tags = new List<(TagModel, long)> { (bug, 1), (ui, 1) }; + return (columns, cards, tags); + } + + private static string Export(CultureInfo culture) + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = culture; + (IReadOnlyList columns, IReadOnlyList cards, IReadOnlyList<(TagModel, long)> tags) = CreateBoard(); + return MarkdownExporter.Export("MyRepo", columns, cards, tags, ExportedAt); + } + finally + { + Resources.Culture = original; + } + } + + private static string Normalize(string text) => text.Replace("\r\n", "\n"); + + [TestMethod] + public void EnglishOutputMatchesTemplate() + { + string created = Local(2026, 9, 1, 10, 0).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + string updated = Local(2026, 9, 28, 18, 30).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + + string expected = Normalize($""" + # MyRepo + > Exported at: 2026-09-30 14:25:37 + + ## Column: In Progress (2) + The work column + + ### #61 Fix login + Body text + - Tags: `bug` `ui` + - Created: {created} Modified: {updated} + + ### #62 + - Created: {created} Modified: {updated} + + ## Column: Empty (0) + + ## Tags + | Name | Color | Description | Usage | + |---|---|---|---| + | bug | #ff0000 | | 1 | + | ui | #00ff00 | Interface work | 1 | + """); + + Assert.AreEqual(expected + "\n", Normalize(Export(new CultureInfo("en")))); + } + + [TestMethod] + public void ChineseOutputMatchesTemplate() + { + var chinese = new CultureInfo("zh-Hans"); + string created = Local(2026, 9, 1, 10, 0).ToString("yyyy年M月d日 HH:mm", chinese); + string updated = Local(2026, 9, 28, 18, 30).ToString("yyyy年M月d日 HH:mm", chinese); + + string expected = Normalize($""" + # MyRepo + > 导出时间:2026年9月30日 14:25 + + ## 列:In Progress(2) + The work column + + ### #61 Fix login + Body text + - 标签:`bug` `ui` + - 创建:{created} 修改:{updated} + + ### #62 + - 创建:{created} 修改:{updated} + + ## 列:Empty(0) + + ## 标签总表 + | 名称 | 颜色 | 描述 | 使用数 | + |---|---|---|---| + | bug | #ff0000 | | 1 | + | ui | #00ff00 | Interface work | 1 | + """); + + Assert.AreEqual(expected + "\n", Normalize(Export(new CultureInfo("zh-Hans")))); + } + + [TestMethod] + public void FileNameFollowsLanguage() + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo("en"); + Assert.AreEqual("MyRepo-export-20260930-142537.md", MarkdownExporter.BuildFileName("MyRepo", ExportedAt)); + + Resources.Culture = new CultureInfo("zh-Hans"); + Assert.AreEqual("MyRepo-导出-20260930-142537.md", MarkdownExporter.BuildFileName("MyRepo", ExportedAt)); + } + finally + { + Resources.Culture = original; + } + } + + [TestMethod] + public void TagTableCellsEscapePipesAndLineBreaks() + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo("en"); + var tag = new TagModel(1, "a|b", RgbColor.Parse("#112233"), "first\nsecond"); + string markdown = MarkdownExporter.Export("W", + [new ColumnModel(1, "C", string.Empty, 1, 1)], + [], + [(tag, 0L)], + ExportedAt); + + StringAssert.Contains(markdown, "| a\\|b | #112233 | first second | 0 |"); + } + finally + { + Resources.Culture = original; + } + } +} diff --git a/YKanBan.Tests/Search/SearchNodeComparer.cs b/YKanBan.Tests/Search/SearchNodeComparer.cs new file mode 100644 index 0000000..3be9c96 --- /dev/null +++ b/YKanBan.Tests/Search/SearchNodeComparer.cs @@ -0,0 +1,54 @@ +using YKanBan.Search; + +namespace YKanBan.Tests.Search; + +/// +/// Structural comparer for search AST nodes, used by assertions only. The +/// AST records compare by reference for their Terms collections by design +/// (see the YYC MARK note in SearchNodes.cs), so tests provide equality +/// explicitly. +/// +public sealed class SearchNodeComparer : IEqualityComparer +{ + /// Shared comparer instance. + public static SearchNodeComparer Instance { get; } = new(); + + /// Determines structural equality of two nodes. + public bool Equals(SearchNode? x, SearchNode? y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + if (x is null || y is null || x.GetType() != y.GetType()) + { + return false; + } + + return (x, y) switch + { + (SearchNode.Or a, SearchNode.Or b) => SequencesEqual(a.Terms, b.Terms), + (SearchNode.And a, SearchNode.And b) => SequencesEqual(a.Terms, b.Terms), + (SearchNode.FreeText a, SearchNode.FreeText b) => a.Text == b.Text, + (SearchNode.FieldText a, SearchNode.FieldText b) => a.Field == b.Field && a.Text == b.Text, + (SearchNode.TagName a, SearchNode.TagName b) => a.Name == b.Name, + (SearchNode.ColumnTitle a, SearchNode.ColumnTitle b) => a.Title == b.Title, + (SearchNode.CardId a, SearchNode.CardId b) => a.Id == b.Id, + _ => false, + }; + } + + /// Not supported: the comparer exists for assertions, not hashing. + public int GetHashCode(SearchNode obj) => + throw new NotSupportedException("Structural comparison for assertions only; hashing is not supported."); + + /// Order-sensitive element-wise comparison. + private static bool SequencesEqual(IReadOnlyList x, IReadOnlyList y) + { + if (x.Count != y.Count) + { + return false; + } + return Enumerable.Zip(x, y, (a, b) => (a, b)).All(pair => Instance.Equals(pair.a, pair.b)); + } +} diff --git a/YKanBan.Tests/Search/SearchQueryParserTests.cs b/YKanBan.Tests/Search/SearchQueryParserTests.cs new file mode 100644 index 0000000..85dee73 --- /dev/null +++ b/YKanBan.Tests/Search/SearchQueryParserTests.cs @@ -0,0 +1,222 @@ +using YKanBan.Search; + +namespace YKanBan.Tests.Search; + +/// +/// Tests for the search parser: the full grammar branches plus the strict +/// failure behavior (malformed input always throws SearchSyntaxException). +/// +[TestClass] +public class SearchQueryParserTests +{ + /// Parses and asserts structural equality against the expected AST. + private static void AssertParsesTo(SearchNode expected, string text) + { + SearchNode? parsed = SearchQueryParser.Parse(text); + Assert.IsNotNull(parsed, $"Blank-looking input: '{text}'."); + Assert.AreEqual(expected, parsed, SearchNodeComparer.Instance); + } + + /// Asserts that the input violates the grammar. + private static void AssertSyntaxError(string text) + { + Assert.ThrowsExactly(() => SearchQueryParser.Parse(text)); + } + + [TestMethod] + public void BlankInputReturnsNull() + { + Assert.IsNull(SearchQueryParser.Parse(string.Empty)); + Assert.IsNull(SearchQueryParser.Parse(" ")); + } + + [TestMethod] + public void SingleWordIsFreeText() + { + AssertParsesTo(new SearchNode.FreeText("login"), "login"); + } + + [TestMethod] + public void AdjacentWordsAreImplicitAnd() + { + AssertParsesTo( + new SearchNode.And([new SearchNode.FreeText("fix"), new SearchNode.FreeText("login")]), + "fix login"); + } + + [TestMethod] + public void ExplicitAndKeyword() + { + AssertParsesTo( + new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]), + "a AND b"); + } + + [TestMethod] + public void OrKeyword() + { + AssertParsesTo( + new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]), + "a OR b"); + } + + [TestMethod] + public void ImplicitAndBindsTighterThanOr() + { + AssertParsesTo( + new SearchNode.Or([ + new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]), + new SearchNode.FreeText("c")]), + "a b OR c"); + } + + [TestMethod] + public void ParenthesesForceGrouping() + { + AssertParsesTo( + new SearchNode.And([ + new SearchNode.FreeText("a"), + new SearchNode.Or([new SearchNode.FreeText("b"), new SearchNode.FreeText("c")])]), + "a (b OR c)"); + } + + [TestMethod] + public void NestedParenthesesCollapse() + { + AssertParsesTo( + new SearchNode.Or([new SearchNode.FreeText("a"), new SearchNode.FreeText("b")]), + "((a OR b))"); + } + + [TestMethod] + public void LowercaseKeywordsArePlainWords() + { + AssertParsesTo( + new SearchNode.And([ + new SearchNode.FreeText("a"), + new SearchNode.FreeText("and"), + new SearchNode.FreeText("or"), + new SearchNode.FreeText("b")]), + "a and or b"); + } + + [TestMethod] + public void PhraseIsFreeText() + { + AssertParsesTo(new SearchNode.FreeText("hello world"), "\"hello world\""); + } + + [TestMethod] + public void PhraseSupportsEscapes() + { + // Escaped quote, escaped backslash, and a lone backslash kept literally. + AssertParsesTo(new SearchNode.FreeText("a\"b"), "\"a\\\"b\""); + AssertParsesTo(new SearchNode.FreeText("a\\b"), "\"a\\\\b\""); + AssertParsesTo(new SearchNode.FreeText("C:\\x"), "\"C:\\x\""); + } + + [TestMethod] + public void TagQualifier() + { + AssertParsesTo(new SearchNode.TagName("bug"), "tag:bug"); + } + + [TestMethod] + public void TagQualifierAcceptsPhraseValue() + { + AssertParsesTo(new SearchNode.TagName("a b"), "tag:\"a b\""); + } + + [TestMethod] + public void TitleAndContentQualifiers() + { + AssertParsesTo(new SearchNode.FieldText(SearchField.Title, "foo"), "title:foo"); + AssertParsesTo(new SearchNode.FieldText(SearchField.Content, "foo"), "content:foo"); + } + + [TestMethod] + public void ColumnQualifierAcceptsPhraseValue() + { + AssertParsesTo(new SearchNode.ColumnTitle("To Do"), "column:\"To Do\""); + } + + [TestMethod] + public void IdQualifierParsesNumber() + { + AssertParsesTo(new SearchNode.CardId(42), "id:42"); + AssertParsesTo(new SearchNode.CardId(12), "id:\"12\""); + } + + [TestMethod] + public void QualifierKeysAreCaseSensitive() + { + AssertParsesTo(new SearchNode.FreeText("Tag:x"), "Tag:x"); + } + + [TestMethod] + public void UnterminatedPhraseIsAnError() + { + AssertSyntaxError("\"abc def"); + } + + [TestMethod] + public void UnbalancedOpenParenIsAnError() + { + AssertSyntaxError("(a"); + } + + [TestMethod] + public void StrayCloseParenIsAnError() + { + AssertSyntaxError("a)"); + } + + [TestMethod] + public void EmptyParensAreAnError() + { + AssertSyntaxError("()"); + } + + [TestMethod] + public void DanglingOrIsAnError() + { + AssertSyntaxError("a OR"); + } + + [TestMethod] + public void DanglingAndIsAnError() + { + AssertSyntaxError("a AND"); + } + + [TestMethod] + public void LeadingAndIsAnError() + { + AssertSyntaxError("AND a"); + } + + [TestMethod] + public void OperatorAtOperandPositionIsAnError() + { + AssertSyntaxError("a AND OR b"); + } + + [TestMethod] + public void QualifierWithoutValueIsAnError() + { + AssertSyntaxError("tag:"); + } + + [TestMethod] + public void NonNumericIdIsAnError() + { + AssertSyntaxError("id:12x"); + } + + [TestMethod] + public void SyntaxErrorCarriesDescriptiveMessage() + { + SearchSyntaxException ex = Assert.ThrowsExactly(() => SearchQueryParser.Parse("a AND")); + Assert.IsFalse(string.IsNullOrEmpty(ex.Message)); + } +} diff --git a/YKanBan.Tests/Search/SearchSqlCompilerTests.cs b/YKanBan.Tests/Search/SearchSqlCompilerTests.cs new file mode 100644 index 0000000..9818468 --- /dev/null +++ b/YKanBan.Tests/Search/SearchSqlCompilerTests.cs @@ -0,0 +1,173 @@ +using Microsoft.Data.Sqlite; +using YKanBan.Search; +using YKanBan.Storage; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Search; + +/// +/// Tests for the query-to-SQL compiler, executed end-to-end against a +/// fixture workspace database. +/// +[TestClass] +public class SearchSqlCompilerTests +{ + /// Owns a workspace with a fixed card/tag fixture. + private sealed class SearchFixture : IDisposable + { + public TempDirectory Directory { get; } = new(); + + public SqliteConnection Connection { get; } + + public SearchFixture() + { + WorkspaceInitializer.Initialize(Directory.FullPath); + using (SqliteConnection setup = SqliteTestHelper.OpenWorkspace(Directory.FullPath)) + { + // Columns: 1 "To Do", 2 "In Progress". + SqliteTestHelper.Exec(setup, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('To Do', '', 1, 1);"); + SqliteTestHelper.Exec(setup, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('In Progress', '', 1, 1);"); + + // Cards: 1 titled, 2 title-or-content matches, 3 untitled, 4 wildcard chars. + SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, 'Fix login', 'Cannot sign in', 1, 1);"); + SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (2, 'Green apple', 'the apple pie', 1, 1);"); + SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (2, '', 'banana split', 1, 1);"); + SqliteTestHelper.Exec(setup, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, '100% done', 'a_b test', 1, 1);"); + + // Tags: mixed case names plus one containing spaces. + SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('Bug', '#ff0000', '');"); + SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('UI', '#00ff00', '');"); + SqliteTestHelper.Exec(setup, "INSERT INTO tags (name, color, description) VALUES ('release notes', '#0000ff', '');"); + SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);"); + SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (2, 2);"); + SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (3, 2);"); + SqliteTestHelper.Exec(setup, "INSERT INTO card_tags (card_id, tag_id) VALUES (4, 3);"); + } + + Connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath); + } + + /// Runs a query and returns the ids of the matching cards. + public List Search(string queryText) + { + SearchNode? query = SearchQueryParser.Parse(queryText); + Assert.IsNotNull(query, "Blank fixture queries are not expected here."); + CompiledSearch compiled = SearchSqlCompiler.Compile(query); + + using var command = Connection.CreateCommand(); + command.CommandText = $"SELECT c.id FROM cards c WHERE {compiled.Predicate} ORDER BY c.id ASC;"; + foreach ((string name, object value) in compiled.Parameters) + { + command.Parameters.AddWithValue(name, value); + } + using var reader = command.ExecuteReader(); + + var ids = new List(); + while (reader.Read()) + { + ids.Add(reader.GetInt64(0)); + } + return ids; + } + + public void Dispose() + { + Connection.Dispose(); + Directory.Dispose(); + } + } + + [TestMethod] + public void FreeTextMatchesTitleOrContentCaseInsensitively() + { + using var fixture = new SearchFixture(); + CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("apple")); + CollectionAssert.AreEqual(new[] { 1L }, fixture.Search("login")); + CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("APPLE")); + CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("pie")); + } + + [TestMethod] + public void FieldQualifiersScopeToTheirField() + { + using var fixture = new SearchFixture(); + CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("title:apple")); + CollectionAssert.AreEqual(new[] { 3L }, fixture.Search("content:banana")); + Assert.AreEqual(0, fixture.Search("title:pie").Count); + } + + [TestMethod] + public void TagQualifierMatchesExactlyAndCaseInsensitively() + { + using var fixture = new SearchFixture(); + CollectionAssert.AreEqual(new[] { 1L }, fixture.Search("tag:bug")); + CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("tag:ui")); + CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("tag:\"release notes\"")); + + // Exact match: prefixes and substrings do not hit. + Assert.AreEqual(0, fixture.Search("tag:bu").Count); + Assert.AreEqual(0, fixture.Search("tag:release").Count); + } + + [TestMethod] + public void ColumnQualifierMatchesColumnTitleExactly() + { + using var fixture = new SearchFixture(); + CollectionAssert.AreEqual(new[] { 1L, 4L }, fixture.Search("column:\"to do\"")); + CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("column:\"in progress\"")); + + // Not a prefix or substring match. + Assert.AreEqual(0, fixture.Search("column:To").Count); + Assert.AreEqual(0, fixture.Search("column:progress").Count); + } + + [TestMethod] + public void IdQualifierMatchesExactly() + { + using var fixture = new SearchFixture(); + CollectionAssert.AreEqual(new[] { 3L }, fixture.Search("id:3")); + Assert.AreEqual(0, fixture.Search("id:99").Count); + } + + [TestMethod] + public void AndOrCombination() + { + using var fixture = new SearchFixture(); + CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("apple OR banana")); + CollectionAssert.AreEqual(new[] { 2L }, fixture.Search("apple AND id:2")); + Assert.AreEqual(0, fixture.Search("apple AND id:1").Count); + } + + [TestMethod] + public void ParenthesizedGrouping() + { + using var fixture = new SearchFixture(); + + // (apple OR banana) hits cards 2,3; intersected with column "In Progress" (2,3). + CollectionAssert.AreEqual(new[] { 2L, 3L }, fixture.Search("(apple OR banana) AND column:\"In Progress\"")); + + // ...while column "To Do" holds cards 1,4 — the intersection is empty. + Assert.AreEqual(0, fixture.Search("(apple OR banana) AND column:\"To Do\"").Count); + } + + [TestMethod] + public void LikeWildcardsInUserTextMatchLiterally() + { + using var fixture = new SearchFixture(); + CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("100%")); + CollectionAssert.AreEqual(new[] { 4L }, fixture.Search("a_b")); + } + + [TestMethod] + public void CompiledPredicateUsesNamedParameters() + { + CompiledSearch compiled = SearchSqlCompiler.Compile(new SearchNode.FreeText("x")); + + StringAssert.Contains(compiled.Predicate, "$p0"); + StringAssert.Contains(compiled.Predicate, "$p1"); + Assert.AreEqual(2, compiled.Parameters.Count); + Assert.AreEqual("%x%", compiled.Parameters[0].Value); + Assert.AreEqual("%x%", compiled.Parameters[1].Value); + } +} diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx index 323a898..d09d982 100644 --- a/YKanBan/Assets/Locales/Resources.resx +++ b/YKanBan/Assets/Locales/Resources.resx @@ -21,4 +21,34 @@ Done + + {0}-export-{1:yyyyMMdd-HHmmss}.md + + + yyyy-MM-dd HH:mm:ss + + + yyyy-MM-dd HH:mm + + + # {0} + + + > Exported at: {0} + + + ## Column: {0} ({1}) + + + - Tags: {0} + + + - Created: {0} Modified: {1} + + + ## Tags + + + | Name | Color | Description | Usage | + diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx index 3b88be7..326e877 100644 --- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -21,4 +21,34 @@ 已完成 + + {0}-导出-{1:yyyyMMdd-HHmmss}.md + + + yyyy年M月d日 HH:mm + + + yyyy年M月d日 HH:mm + + + # {0} + + + > 导出时间:{0} + + + ## 列:{0}({1}) + + + - 标签:{0} + + + - 创建:{0} 修改:{1} + + + ## 标签总表 + + + | 名称 | 颜色 | 描述 | 使用数 | + diff --git a/YKanBan/Export/MarkdownExporter.cs b/YKanBan/Export/MarkdownExporter.cs new file mode 100644 index 0000000..6a992f3 --- /dev/null +++ b/YKanBan/Export/MarkdownExporter.cs @@ -0,0 +1,117 @@ +using System.Globalization; +using System.Text; +using YKanBan.Models; + +namespace YKanBan.Export; + +/// +/// Generates the human-readable Markdown snapshot of a whole board. All +/// template text and time formats come from the ResX resources in the +/// current UI language; the export timestamp uses the per-language pattern, +/// card timestamps use the shared minute-precision pattern. The output is +/// meant for reading only — there is no re-import. +/// +public static class MarkdownExporter +{ + /// Renders the whole board to Markdown. + /// Workspace display name (the folder name). + /// All columns, in id order; empty columns are kept. + /// All cards; grouped by column, id order within a column. + /// All tags with usage counts, in id order. + /// Export moment in local time. + public static string Export( + string workspaceName, + IReadOnlyList columns, + IReadOnlyList cards, + IReadOnlyList<(TagModel Tag, long UsageCount)> tags, + DateTimeOffset exportedAt) + { + CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture; + + var builder = new StringBuilder(); + + // Header: workspace name and the export timestamp in the UI language's format. + builder.AppendLine(string.Format(culture, Resources.Export_WorkspaceHeading, workspaceName)); + builder.AppendLine(string.Format( + culture, Resources.Export_ExportedAtLine, exportedAt.ToString(Resources.Export_TimePattern, culture))); + builder.AppendLine(); + + // Board body: every column in id order, empty ones included. + foreach (ColumnModel column in columns) + { + IReadOnlyList cardsInColumn = cards.Where(card => card.ColumnId == column.Id).ToArray(); + builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, column.Title, cardsInColumn.Count)); + if (column.Description.Length > 0) + { + builder.AppendLine(SingleLine(column.Description)); + } + builder.AppendLine(); + + foreach (CardModel card in cardsInColumn) + { + AppendCard(builder, card, culture); + } + } + + AppendTagTable(builder, tags, culture); + return builder.ToString(); + } + + /// Builds the default export file name in the UI language, e.g. "MyRepo-export-20260930-142537.md". + public static string BuildFileName(string workspaceName, DateTimeOffset exportedAt) + { + CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture; + return string.Format(culture, Resources.Export_FileNamePattern, workspaceName, exportedAt); + } + + /// Appends one card block: heading, body, tags line, timestamps line. + private static void AppendCard(StringBuilder builder, CardModel card, CultureInfo culture) + { + // Heading: "#id title"; an untitled card shows only the id. + builder.AppendLine(card.Title.Length == 0 + ? $"### #{card.Id}" + : $"### #{card.Id} {SingleLine(card.Title)}"); + + if (card.Content.Length > 0) + { + builder.AppendLine(card.Content); + } + + if (card.Tags.Count > 0) + { + string names = string.Join(" ", card.Tags.Select(tag => $"`{tag.Name}`")); + builder.AppendLine(string.Format(culture, Resources.Export_TagsLine, names)); + } + + string created = ToLocalTime(card.CreatedAt).ToString(Resources.Export_CardTimePattern, culture); + string updated = ToLocalTime(card.UpdatedAt).ToString(Resources.Export_CardTimePattern, culture); + builder.AppendLine(string.Format(culture, Resources.Export_CreatedModifiedLine, created, updated)); + builder.AppendLine(); + } + + /// Appends the tag summary table with usage counts; tags with zero usage are included. + private static void AppendTagTable( + StringBuilder builder, + IReadOnlyList<(TagModel Tag, long UsageCount)> tags, + CultureInfo culture) + { + builder.AppendLine(Resources.Export_TagsSummaryHeading); + builder.AppendLine(Resources.Export_TagsTableHeader); + builder.AppendLine("|---|---|---|---|"); + foreach ((TagModel tag, long usage) in tags) + { + // '|' and line breaks would break the table row; neutralize them. + builder.AppendLine($"| {Cell(tag.Name)} | {tag.Color} | {Cell(tag.Description)} | {usage} |"); + } + } + + /// Formats a table cell: single line with escaped pipe characters. + private static string Cell(string text) => SingleLine(text).Replace("|", "\\|"); + + /// Collapses line breaks so the text stays on one Markdown line. + private static string SingleLine(string text) => text.Replace("\r", string.Empty).Replace("\n", " "); + + /// Converts stored Unix seconds to a local-time DateTimeOffset. + private static DateTimeOffset ToLocalTime(long unixSeconds) => + DateTimeOffset.FromUnixTimeSeconds(unixSeconds).ToLocalTime(); +} diff --git a/YKanBan/Search/SearchLexer.cs b/YKanBan/Search/SearchLexer.cs new file mode 100644 index 0000000..3db3bf0 --- /dev/null +++ b/YKanBan/Search/SearchLexer.cs @@ -0,0 +1,73 @@ +using Pidgin; + +namespace YKanBan.Search; + +/// Lexical tokens of the search syntax. +public abstract record SearchToken +{ + /// A bare word; qualifier detection happens later from the text. + public sealed record Word(string Text) : SearchToken; + + /// A quoted phrase with escapes already resolved. + public sealed record Phrase(string Text) : SearchToken; + + /// An opening parenthesis. + public sealed record OpenParen : SearchToken; + + /// A closing parenthesis. + public sealed record CloseParen : SearchToken; +} + +/// +/// Pidgin-based lexer for the search syntax. Splits the input into words, +/// quoted phrases (with \" and \\ escapes) and parentheses; whitespace only +/// separates tokens. The lexer is total: it never rejects input — anything +/// the dedicated rules do not cover falls through to the word rule, and an +/// unterminated phrase simply extends to the end of the input. +/// +public static class SearchLexer +{ + private static readonly Parser PhraseCharacter = + Parser.Try(Parser.String("\\\"").Select(_ => '"')) + .Or(Parser.Try(Parser.String("\\\\").Select(_ => '\\'))) + .Or(Parser.AnyCharExcept('"')); + + private static readonly Parser PhraseParser = + from openingQuote in Parser.Char('"') + from content in PhraseCharacter.Many() + from closingQuote in Parser.Char('"') + select (SearchToken)new SearchToken.Phrase(new string(content.ToArray())); + + private static readonly Parser OpenParenParser = + Parser.Char('(').Select(_ => (SearchToken)new SearchToken.OpenParen()); + + private static readonly Parser CloseParenParser = + Parser.Char(')').Select(_ => (SearchToken)new SearchToken.CloseParen()); + + private static readonly Parser WordParser = + Parser.Token(c => !char.IsWhiteSpace(c) && c != '(' && c != ')' && c != '"') + .AtLeastOnce() + .Select(chars => (SearchToken)new SearchToken.Word(new string(chars.ToArray()))); + + private static readonly Parser AnyCharacterAsWord = + Parser.Any.Select(c => (SearchToken)new SearchToken.Word(c.ToString())); + + private static readonly Parser TokenParser = + Parser.SkipWhitespaces.Then( + PhraseParser + .Or(OpenParenParser) + .Or(CloseParenParser) + .Or(WordParser) + .Or(AnyCharacterAsWord)); + + private static readonly Parser> Lexer = + // Try(...) keeps a trailing-whitespace failure non-consuming so Many stops + // gracefully; the trailing Before(...) then swallows the leftover whitespace. + Parser.Try(TokenParser).Many() + .Before(Parser.SkipWhitespaces) + .Select(tokens => (IReadOnlyList)tokens.ToList()); + + /// Tokenizes the search text; total over any input. + public static IReadOnlyList Tokenize(string input) => + Lexer.ParseOrThrow(input); +} diff --git a/YKanBan/Search/SearchNodes.cs b/YKanBan/Search/SearchNodes.cs new file mode 100644 index 0000000..cc55f02 --- /dev/null +++ b/YKanBan/Search/SearchNodes.cs @@ -0,0 +1,45 @@ +// YYC MARK: C# records synthesize equality by running EqualityComparer.Default +// on each positional property. For collection properties (IReadOnlyList<>, +// ImmutableArray<>, arrays, ...) that comparer falls back to REFERENCE equality, +// so the And/Or nodes below deliberately do NOT compare structurally. The only +// structural comparison lives in the test helper (SearchNodeComparer); production +// code never compares AST nodes. +namespace YKanBan.Search; + +/// +/// Root of the search query AST. Search keywords are fixed English grammar +/// and are never localized. +/// +public abstract record SearchNode +{ + /// Disjunction: matches when any term matches (OR). + public sealed record Or(IReadOnlyList Terms) : SearchNode; + + /// Conjunction: matches when all terms match (explicit AND or adjacency). + public sealed record And(IReadOnlyList Terms) : SearchNode; + + /// A bare word or phrase matched as a substring of the title OR content. + public sealed record FreeText(string Text) : SearchNode; + + /// A substring match scoped to one card field. + public sealed record FieldText(SearchField Field, string Text) : SearchNode; + + /// An exact, case-insensitive tag name match. + public sealed record TagName(string Name) : SearchNode; + + /// An exact, case-insensitive column title match. + public sealed record ColumnTitle(string Title) : SearchNode; + + /// An exact card id match. + public sealed record CardId(long Id) : SearchNode; +} + +/// The card fields a atom can scope to. +public enum SearchField +{ + /// The card title. + Title, + + /// The card content body. + Content, +} diff --git a/YKanBan/Search/SearchQueryParser.cs b/YKanBan/Search/SearchQueryParser.cs new file mode 100644 index 0000000..61357ec --- /dev/null +++ b/YKanBan/Search/SearchQueryParser.cs @@ -0,0 +1,194 @@ +using System.Globalization; +using Pidgin; + +namespace YKanBan.Search; + +/// +/// Thrown when the search text violates the search grammar. Carries the +/// underlying parser's descriptive message (including the error position); +/// the UI surfaces it as a search error instead of coupling to Pidgin types. +/// +public sealed class SearchSyntaxException : Exception +{ + /// Creates an exception wrapping the original parse failure. + public SearchSyntaxException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// Parses the search grammar with Pidgin, strictly: any malformed input +/// (unbalanced parentheses, dangling operators, unterminated phrases, +/// qualifier keys without a value, non-numeric id values) raises +/// . Only uppercase AND/OR are operators +/// (lowercase ones are plain words); AND — explicit or by adjacency — binds +/// tighter than OR; parentheses group. Blank input returns null and means +/// "show everything". +/// +public static class SearchQueryParser +{ + /// Recognized qualifier kinds; extend to add new keys. + private enum QualifierKind + { + Tag, + Title, + Content, + Id, + Column, + } + + /// The qualifier key table ("key:" prefixes); reserved for future keys like created:. + private static readonly (string Key, QualifierKind Kind)[] Qualifiers = + [ + ("tag", QualifierKind.Tag), + ("title", QualifierKind.Title), + ("content", QualifierKind.Content), + ("id", QualifierKind.Id), + ("column", QualifierKind.Column), + ]; + + #region Token primitives + + private static readonly Parser AndKeyword = + Parser.Token(new SearchToken.Word("AND")); + + private static readonly Parser OrKeyword = + Parser.Token(new SearchToken.Word("OR")); + + private static readonly Parser OpenParen = + Parser.Token((SearchToken)new SearchToken.OpenParen()); + + private static readonly Parser CloseParen = + Parser.Token((SearchToken)new SearchToken.CloseParen()); + + /// The text of a phrase token. + private static readonly Parser PhraseText = + Parser.Token(t => t is SearchToken.Phrase) + .Select(t => ((SearchToken.Phrase)t).Text); + + /// The text of a word token, operator keywords excluded (they never start an operand). + private static readonly Parser NonKeywordWord = + Parser.Token(t => t is SearchToken.Word w && w.Text != "AND" && w.Text != "OR") + .Select(t => ((SearchToken.Word)t).Text); + + #endregion + + #region Grammar + + // A bare phrase matched against title or content. + private static readonly Parser FreePhrase = + PhraseText.Select(text => (SearchNode)new SearchNode.FreeText(text)); + + // A bare word: a "key:value" qualifier when the prefix matches, otherwise free text. + private static readonly Parser WordAtom = + NonKeywordWord.Bind(QualifierFrom); + + // '(' orExpr ')' — the deferred method call breaks the + // Parenthesized → OrExpr → AndExpr → Operand → Parenthesized recursion cycle. + private static readonly Parser Parenthesized = + OpenParen.Then(_ => OrExprDeferred()).Before(CloseParen); + + // operand := '(' orExpr ')' | qualifier-or-word | phrase + private static readonly Parser Operand = + Parenthesized.Or(WordAtom).Or(FreePhrase); + + // One andExpr operand together with its optional explicit AND keyword. + private static readonly Parser AndPrefixedOperand = + AndKeyword.Optional().Then(Operand); + + // andExpr := operand (AND? operand)* + private static readonly Parser AndExpr = + Operand.Bind(first => + AndPrefixedOperand.Many() + .Select(rest => FoldAnd(first, rest))); + + // orExpr := andExpr (OR andExpr)+ + private static readonly Parser OrExpr = + AndExpr.SeparatedAtLeastOnce(OrKeyword) + .Select(FoldOr); + + // The full query: orExpr consuming the whole token stream. + private static readonly Parser Query = + OrExpr.Before(Parser.End); + + /// Defers the OrExpr field read to parse time (recursion cycle breaker). + private static Parser OrExprDeferred() => OrExpr; + + #endregion + + /// Parses a search box text; returns null for blank input (meaning: show everything). + /// The text violates the grammar. + public static SearchNode? Parse(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + try + { + List tokens = SearchLexer.Tokenize(text).ToList(); + return Query.ParseOrThrow(tokens); + } + catch (ParseException ex) + { + throw new SearchSyntaxException(ex.Message, ex); + } + } + + /// Builds the parser for one word operand: qualifier atom or free text. + private static Parser QualifierFrom(string word) + { + foreach ((string key, QualifierKind kind) in Qualifiers) + { + // Qualifier keys are case-sensitive lowercase grammar tokens. + if (!word.StartsWith(key + ":", StringComparison.Ordinal)) + { + continue; + } + string value = word[(key.Length + 1)..]; + + // "key:" without an inline value takes the value from the following phrase, e.g. tag:"a b". + return value.Length == 0 + ? PhraseText.Bind(text => AtomParser(kind, text)) + : AtomParser(kind, value); + } + + // No qualifier prefix: a plain free-text word. + return Parser.Return(new SearchNode.FreeText(word)); + } + + /// Builds the atom parser for a qualifier value, failing strictly on a non-numeric id. + private static Parser AtomParser(QualifierKind kind, string value) + { + return AtomFrom(kind, value) is { } atom + ? Parser.Return(atom) + : Parser.Fail($"id: expects an integer, got '{value}'."); + } + + /// Builds the atom for a qualifier value; null when an id value is not an integer. + private static SearchNode? AtomFrom(QualifierKind kind, string value) => kind switch + { + QualifierKind.Tag => new SearchNode.TagName(value), + QualifierKind.Title => new SearchNode.FieldText(SearchField.Title, value), + QualifierKind.Content => new SearchNode.FieldText(SearchField.Content, value), + QualifierKind.Column => new SearchNode.ColumnTitle(value), + QualifierKind.Id when long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long id) => new SearchNode.CardId(id), + _ => null, + }; + + /// Collapses an AND chain into a single node. + private static SearchNode FoldAnd(SearchNode first, IEnumerable rest) + { + List terms = [first, .. rest]; + return terms.Count == 1 ? terms[0] : new SearchNode.And(terms); + } + + /// Collapses an OR chain into a single node. + private static SearchNode FoldOr(IEnumerable terms) + { + List list = terms.ToList(); + return list.Count == 1 ? list[0] : new SearchNode.Or(list); + } +} diff --git a/YKanBan/Search/SearchSqlCompiler.cs b/YKanBan/Search/SearchSqlCompiler.cs new file mode 100644 index 0000000..e6f9692 --- /dev/null +++ b/YKanBan/Search/SearchSqlCompiler.cs @@ -0,0 +1,86 @@ +namespace YKanBan.Search; + +/// A compiled search predicate: a SQL fragment over the "c" (cards) alias plus its bound parameters. +/// SQL boolean expression; intended for "SELECT ... FROM cards c WHERE {Predicate}". +/// Named parameters referenced by the predicate, in order. +public sealed record CompiledSearch(string Predicate, IReadOnlyList<(string Name, object Value)> Parameters); + +/// +/// Compiles a parsed search AST into a parameterized SQL predicate. +/// Matching is case-insensitive with SQLite's built-in ASCII semantics: +/// LIKE's default case folding plus COLLATE NOCASE for exact matches. +/// LIKE wildcards and the escape character in user text are escaped so they +/// match literally. +/// +public static class SearchSqlCompiler +{ + /// Compiles the query into a SQL predicate. + public static CompiledSearch Compile(SearchNode query) + { + var parameters = new List<(string Name, object Value)>(); + string predicate = Build(query, parameters); + return new CompiledSearch(predicate, parameters); + } + + /// Recursively builds the SQL expression for one node. + private static string Build(SearchNode node, List<(string Name, object Value)> parameters) + { + switch (node) + { + case SearchNode.And and: + return "(" + string.Join(" AND ", and.Terms.Select(term => Build(term, parameters))) + ")"; + + case SearchNode.Or or: + return "(" + string.Join(" OR ", or.Terms.Select(term => Build(term, parameters))) + ")"; + + case SearchNode.FreeText freeText: + { + // A bare word matches a substring of the title OR the content. + string titleParam = AddParameter(parameters, "%" + EscapeLike(freeText.Text) + "%"); + string contentParam = AddParameter(parameters, "%" + EscapeLike(freeText.Text) + "%"); + return $"(c.title LIKE {titleParam} ESCAPE '\\' OR c.content LIKE {contentParam} ESCAPE '\\')"; + } + + case SearchNode.FieldText field: + { + string column = field.Field == SearchField.Title ? "c.title" : "c.content"; + string param = AddParameter(parameters, "%" + EscapeLike(field.Text) + "%"); + return $"{column} LIKE {param} ESCAPE '\\'"; + } + + case SearchNode.TagName tag: + { + string param = AddParameter(parameters, tag.Name); + return "EXISTS (SELECT 1 FROM card_tags ct JOIN tags t ON t.id = ct.tag_id " + + $"WHERE ct.card_id = c.id AND t.name COLLATE NOCASE = {param})"; + } + + case SearchNode.ColumnTitle column: + { + string param = AddParameter(parameters, column.Title); + return $"c.column_id IN (SELECT id FROM columns WHERE title COLLATE NOCASE = {param})"; + } + + case SearchNode.CardId id: + { + string param = AddParameter(parameters, id.Id); + return $"c.id = {param}"; + } + + default: + throw new InvalidOperationException($"Unsupported search node: {node.GetType().Name}"); + } + } + + /// Binds a value under a fresh name and returns the SQL placeholder. + private static string AddParameter(List<(string Name, object Value)> parameters, object value) + { + string name = "$p" + parameters.Count.ToString(System.Globalization.CultureInfo.InvariantCulture); + parameters.Add((name, value)); + return name; + } + + /// Escapes LIKE wildcards and the escape character inside user text. + private static string EscapeLike(string text) => + text.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_"); +}