diff --git a/YKanBan.Tests/Export/MarkdownExporterTests.cs b/YKanBan.Tests/Export/MarkdownExporterTests.cs new file mode 100644 index 0000000..c7f9a75 --- /dev/null +++ b/YKanBan.Tests/Export/MarkdownExporterTests.cs @@ -0,0 +1,206 @@ +using System.Globalization; +using System.Text; +using YKanBan.Export; +using YKanBan.Models; + +namespace YKanBan.Tests.Export; + +/// +/// Tests for the Markdown exporter. Mutates the global +/// , therefore marked do-not-parallelize. +/// Expected documents are assembled from the same ResX template keys as the +/// exporter (plus fixed fixture values), so no localized text is hard-coded +/// here. +/// +[TestClass] +[DoNotParallelize] +public class MarkdownExporterTests +{ + private static readonly DateTimeOffset ExportedAt = new(2026, 9, 30, 14, 25, 37, TimeSpan.Zero); + + /// + /// Builds a local wall-clock moment, matching the exporter's conversion path. + /// + /// The year. + /// The month. + /// The day. + /// The hour. + /// The minute. + /// The local time. + 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(); + + /// + /// Converts a local wall-clock time to Unix seconds. + /// + /// The year. + /// The month. + /// The day. + /// The hour. + /// The minute. + /// The Unix timestamp. + 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 and two tags. + /// + /// The columns, cards and tag/usage tuples. + 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); + } + + /// + /// Renders the fixture board with the exporter under the current culture. + /// + /// The produced Markdown document. + private static string ExportBoard() + { + (IReadOnlyList columns, IReadOnlyList cards, IReadOnlyList<(TagModel, long)> tags) = CreateBoard(); + return MarkdownExporter.Export("MyRepo", columns, cards, tags, ExportedAt); + } + + /// + /// Assembles the expected document from the ResX template keys under the current culture. + /// + /// The UI culture. + /// The expected Markdown document. + private static string BuildExpected(CultureInfo culture) + { + string created = Local(2026, 9, 1, 10, 0).ToString(Resources.Export_CardTimePattern, culture); + string updated = Local(2026, 9, 28, 18, 30).ToString(Resources.Export_CardTimePattern, culture); + + var builder = new StringBuilder(); + + // Header: workspace name and the export timestamp. + builder.AppendLine(string.Format(culture, Resources.Export_WorkspaceHeading, "MyRepo")); + builder.AppendLine(string.Format( + culture, Resources.Export_ExportedAtLine, ExportedAt.ToString(Resources.Export_TimePattern, culture))); + builder.AppendLine(); + + // First column with its two cards. + builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, "In Progress", 2)); + builder.AppendLine("The work column"); + builder.AppendLine(); + builder.AppendLine("### #61 Fix login"); + builder.AppendLine("Body text"); + builder.AppendLine(string.Format(culture, Resources.Export_TagsLine, "`bug` `ui`")); + builder.AppendLine(string.Format(culture, Resources.Export_CreatedModifiedLine, created, updated)); + builder.AppendLine(); + builder.AppendLine("### #62"); + builder.AppendLine(string.Format(culture, Resources.Export_CreatedModifiedLine, created, updated)); + builder.AppendLine(); + + // Empty column is kept. + builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, "Empty", 0)); + builder.AppendLine(); + + // Tag summary table. + builder.AppendLine(Resources.Export_TagsSummaryHeading); + builder.AppendLine(Resources.Export_TagsTableHeader); + builder.AppendLine("|---|---|---|---|"); + builder.AppendLine("| bug | #ff0000 | | 1 |"); + builder.AppendLine("| ui | #00ff00 | Interface work | 1 |"); + + return builder.ToString(); + } + + /// + /// Normalizes CRLF line endings so expected and actual compare consistently. + /// + /// The text to normalize. + /// The text with normalized line endings. + private static string Normalize(string text) => text.Replace("\r\n", "\n"); + + /// + /// Asserts that the exporter output matches the resource-built expected document. + /// + /// The UI culture to test. + private static void AssertOutputMatches(CultureInfo culture) + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = culture; + Assert.AreEqual(Normalize(BuildExpected(culture)), Normalize(ExportBoard())); + } + finally + { + Resources.Culture = original; + } + } + + [TestMethod] + public void EnglishOutputMatchesTemplate() + { + AssertOutputMatches(new CultureInfo("en")); + } + + [TestMethod] + public void ChineseOutputMatchesTemplate() + { + AssertOutputMatches(new CultureInfo("zh-Hans")); + } + + [TestMethod] + public void FileNameFollowsLanguage() + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo("en"); + Assert.AreEqual( + string.Format(Resources.Culture, Resources.Export_FileNamePattern, "MyRepo", ExportedAt), + MarkdownExporter.BuildFileName("MyRepo", ExportedAt)); + + Resources.Culture = new CultureInfo("zh-Hans"); + Assert.AreEqual( + string.Format(Resources.Culture, Resources.Export_FileNamePattern, "MyRepo", ExportedAt), + 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..652c3a4 --- /dev/null +++ b/YKanBan.Tests/Search/SearchNodeComparer.cs @@ -0,0 +1,69 @@ +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 +{ + /// + /// Gets the shared comparer instance. + /// + public static SearchNodeComparer Instance { get; } = new(); + + /// + /// Determines structural equality of two nodes. + /// + /// The first node. + /// The second node. + /// when the nodes are structurally equal. + 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. + /// + /// The node. + /// Never returns. + public int GetHashCode(SearchNode obj) => + throw new NotSupportedException("Structural comparison for assertions only; hashing is not supported."); + + /// + /// Order-sensitive element-wise comparison. + /// + /// The first sequence. + /// The second sequence. + /// when both sequences match element-wise. + private static bool SequencesEqual(IReadOnlyList x, IReadOnlyList y) + { + if (x.Count != y.Count) + { + return false; + } + return x.Zip(y).All(pair => Instance.Equals(pair.First, pair.Second)); + } +} diff --git a/YKanBan.Tests/Search/SearchQueryParserTests.cs b/YKanBan.Tests/Search/SearchQueryParserTests.cs new file mode 100644 index 0000000..d17ce04 --- /dev/null +++ b/YKanBan.Tests/Search/SearchQueryParserTests.cs @@ -0,0 +1,292 @@ +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 +/// ). +/// +[TestClass] +public class SearchQueryParserTests +{ + /// + /// Parses and asserts structural equality against the expected AST. + /// + /// The expected AST. + /// The search text. + 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. + /// + /// The search text. + private static void AssertSyntaxError(string text) + { + Assert.ThrowsExactly(() => SearchQueryParser.Parse(text)); + } + + #region Successful parses + + [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 QuotedOperatorKeywordIsFreeText() + { + // A quoted AND/OR is literal text, not an operator. + AssertParsesTo(new SearchNode.FreeText("AND"), "\"AND\""); + AssertParsesTo( + new SearchNode.And([new SearchNode.FreeText("a"), new SearchNode.FreeText("OR")]), + "a \"OR\""); + } + + [TestMethod] + public void PhraseSupportsEscapes() + { + // Escaped quote and escaped backslash. + AssertParsesTo(new SearchNode.FreeText("a\"b"), "\"a\\\"b\""); + AssertParsesTo(new SearchNode.FreeText("a\\b"), "\"a\\\\b\""); + } + + [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 QualifierValueMayContainColons() + { + 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 ColonWithoutKeyPrefixIsFreeText() + { + AssertParsesTo(new SearchNode.FreeText(":foo"), ":foo"); + } + + #endregion + + #region Strict failures + + [TestMethod] + public void UnterminatedPhraseIsAnError() + { + AssertSyntaxError("\"abc def"); + } + + [TestMethod] + public void TrailingUnterminatedPhraseIsAnError() + { + AssertSyntaxError("a \"abc"); + } + + [TestMethod] + public void InvalidEscapeInPhraseIsAnError() + { + // A bare backslash (not followed by a quote or another backslash) is invalid. + AssertSyntaxError("\"a\\b\""); + } + + [TestMethod] + public void InvalidEscapeInQualifierPhraseIsAnError() + { + AssertSyntaxError("tag:\"a\\x\""); + } + + [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 SpacedQualifierValueIsAnError() + { + AssertSyntaxError("tag: bug"); + } + + [TestMethod] + public void NonNumericIdIsAnError() + { + AssertSyntaxError("id:12x"); + } + + [TestMethod] + public void UnknownQualifierKeyIsAnError() + { + AssertSyntaxError("foo:bar"); + } + + [TestMethod] + public void QualifierKeysAreCaseSensitive() + { + // "Tag" is a key-like prefix but not the known lowercase "tag", so it is unknown. + AssertSyntaxError("Tag:x"); + } + + [TestMethod] + public void SyntaxErrorCarriesDescriptiveMessage() + { + SearchSyntaxException exception = Assert.ThrowsExactly( + () => SearchQueryParser.Parse("a AND")); + Assert.IsFalse(string.IsNullOrEmpty(exception.Message)); + } + + #endregion +} diff --git a/YKanBan.Tests/Search/SearchSqlCompilerTests.cs b/YKanBan.Tests/Search/SearchSqlCompilerTests.cs new file mode 100644 index 0000000..836b4f5 --- /dev/null +++ b/YKanBan.Tests/Search/SearchSqlCompilerTests.cs @@ -0,0 +1,178 @@ +using Microsoft.Data.Sqlite; +using YKanBan.Search; +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. + /// + /// The search text. + /// The matching card ids in id order. + 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 8e09503..8a6f4c9 100644 --- a/YKanBan/Assets/Locales/Resources.resx +++ b/YKanBan/Assets/Locales/Resources.resx @@ -25,4 +25,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 6eba378..162785e 100644 --- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -25,4 +25,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..e126545 --- /dev/null +++ b/YKanBan/Export/MarkdownExporter.cs @@ -0,0 +1,148 @@ +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. + /// The Markdown document. + 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, for example + /// "MyRepo-export-20260930-142537.md". + /// + /// Workspace display name. + /// Export moment. + /// The file name. + 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 and timestamps line. + /// + /// The output builder. + /// The card to render. + /// The UI culture. + 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. + /// + /// The output builder. + /// The tags and their usage counts. + /// The UI culture. + 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. + /// + /// The raw cell text. + /// The escaped single-line text. + private static string Cell(string text) => SingleLine(text).Replace("|", "\\|"); + + /// + /// Collapses line breaks so the text stays on one Markdown line. + /// + /// The raw text. + /// The text without line breaks. + private static string SingleLine(string text) => text.Replace("\r", string.Empty).Replace("\n", " "); + + /// + /// Converts stored Unix seconds to a local-time . + /// + /// The stored timestamp. + /// The local time. + 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..0da560b --- /dev/null +++ b/YKanBan/Search/SearchLexer.cs @@ -0,0 +1,104 @@ +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. + /// + /// The word text. + public sealed record Word(string Text) : SearchToken; + + /// + /// A quoted phrase with its escapes already resolved. + /// + /// The unescaped phrase content. + 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 and parentheses; whitespace only separates tokens. +/// +/// The lexer is strict: inside a phrase only \" and \\ are valid +/// escapes, a bare backslash or an unknown escape is rejected, and an +/// unterminated phrase is rejected. Any input not covered by the token rules +/// is rejected rather than silently ignored. +/// +public static class SearchLexer +{ + /// A valid phrase escape sequence: \" or \\. + private static readonly Parser PhraseEscape = + Parser.Try(Parser.String("\\\"").Select(_ => '"')) + .Or(Parser.String("\\\\").Select(_ => '\\')); + + /// One phrase character: an escape or any character except a quote or backslash. + private static readonly Parser PhraseCharacter = + PhraseEscape.Or(Parser.Token(character => character != '"' && character != '\\')); + + 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(character => + !char.IsWhiteSpace(character) && character != '(' && character != ')' && character != '"') + .AtLeastOnce() + .Select(chars => (SearchToken)new SearchToken.Word(new string(chars.ToArray()))); + + private static readonly Parser TokenParser = + Parser.SkipWhitespaces.Then( + PhraseParser + .Or(OpenParenParser) + .Or(CloseParenParser) + .Or(WordParser)); + + // Try(...) keeps a failed token non-consuming so Many stops gracefully; End + // then rejects any leftover input, which turns dangling quotes or invalid + // escapes into hard failures instead of silently dropped text. + private static readonly Parser> Lexer = + Parser.Try(TokenParser).Many() + .Before(Parser.SkipWhitespaces) + .Before(Parser.End) + .Select(tokens => (IReadOnlyList)tokens.ToList()); + + /// + /// Tokenizes the search text. + /// + /// The raw search text. + /// The token stream. + /// The text contains an invalid escape or an unterminated phrase. + public static IReadOnlyList Tokenize(string input) + { + try + { + return Lexer.ParseOrThrow(input); + } + catch (ParseException exception) + { + throw new SearchSyntaxException(exception.Message, exception); + } + } +} diff --git a/YKanBan/Search/SearchNodes.cs b/YKanBan/Search/SearchNodes.cs new file mode 100644 index 0000000..5609615 --- /dev/null +++ b/YKanBan/Search/SearchNodes.cs @@ -0,0 +1,69 @@ +// 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 Or/And 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). + /// + /// The alternative terms. + public sealed record Or(IReadOnlyList Terms) : SearchNode; + + /// + /// Conjunction: matches when all terms match (explicit AND or adjacency). + /// + /// The required terms. + public sealed record And(IReadOnlyList Terms) : SearchNode; + + /// + /// A bare word or phrase matched as a substring of the title OR content. + /// + /// The search text. + public sealed record FreeText(string Text) : SearchNode; + + /// + /// A substring match scoped to one card field. + /// + /// The card field to match against. + /// The search text. + public sealed record FieldText(SearchField Field, string Text) : SearchNode; + + /// + /// An exact, case-insensitive tag name match. + /// + /// The tag name to match. + public sealed record TagName(string Name) : SearchNode; + + /// + /// An exact, case-insensitive column title match. + /// + /// The column title to match. + public sealed record ColumnTitle(string Title) : SearchNode; + + /// + /// An exact card id match. + /// + /// The card id to 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..47b8054 --- /dev/null +++ b/YKanBan/Search/SearchQueryParser.cs @@ -0,0 +1,257 @@ +using System.Globalization; +using Pidgin; + +namespace YKanBan.Search; + +/// +/// Parses the search grammar with Pidgin, strictly: any malformed input +/// (unbalanced parentheses, dangling or consecutive operators, unterminated +/// phrases, invalid escapes, qualifier keys without a value or with an +/// unknown/non-numeric id value) 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". Search keywords and +/// qualifier keys are fixed English grammar and are never localized. +/// +public static class SearchQueryParser +{ + /// The recognized qualifier keys; add new entries here to extend the grammar. + private static readonly (string Key, QualifierKind Kind)[] QualifierTable = + [ + ("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(token => token is SearchToken.Word { Text: "AND" }); + + private static readonly Parser OrKeyword = + Parser.Token(token => token is SearchToken.Word { Text: "OR" }); + + private static readonly Parser OpenParen = + Parser.Token(token => token is SearchToken.OpenParen); + + private static readonly Parser CloseParen = + Parser.Token(token => token is SearchToken.CloseParen); + + /// The text of a phrase token. + private static readonly Parser PhraseText = + Parser.Token(token => token is SearchToken.Phrase) + .Select(token => ((SearchToken.Phrase)token).Text); + + /// The text of a word token, operator keywords excluded (they never start an operand). + private static readonly Parser NonKeywordWord = + Parser.Token(token => token is SearchToken.Word word && word.Text != "AND" && word.Text != "OR") + .Select(token => ((SearchToken.Word)token).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). + /// The OR-expression parser. + private static Parser OrExprDeferred() => OrExpr; + + #endregion + + /// + /// Parses a search box text. + /// + /// The raw search text. + /// The parsed query, or 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 exception) + { + throw new SearchSyntaxException(exception.Message, exception); + } + } + + #region Atom construction + + /// + /// Builds the parser for one word operand: a qualifier atom, an unknown-key + /// failure, or free text. + /// + /// The word token text. + /// The parser producing the operand node. + private static Parser QualifierFrom(string word) + { + int colonIndex = word.IndexOf(':'); + + // Only a leading run of letters/digits/underscores before ':' can be a qualifier key. + if (colonIndex <= 0 || !IsQualifierKeyCandidate(word.AsSpan(0, colonIndex))) + { + return Parser.Return(new SearchNode.FreeText(word)); + } + + string key = word[..colonIndex]; + string value = word[(colonIndex + 1)..]; + + if (!TryLookupQualifier(key, out QualifierKind kind)) + { + return Parser.Fail($"Unknown qualifier key '{key}'."); + } + + // "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); + } + + /// + /// Builds the atom parser for a qualifier value, failing strictly on a non-numeric id. + /// + /// The qualifier kind. + /// The qualifier value. + /// The parser producing the atom node. + private static Parser AtomParser(QualifierKind kind, string value) => + AtomFrom(kind, value) is { } atom + ? Parser.Return(atom) + : Parser.Fail($"The id qualifier expects an integer, got '{value}'."); + + /// + /// Builds the atom for a qualifier value; null when an id value is not an integer. + /// + /// The qualifier kind. + /// The qualifier value. + /// The atom node, or for an invalid id value. + 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, + }; + + /// + /// Looks up a qualifier key in the extension table with ordinal (case-sensitive) matching. + /// + /// The key text before the colon. + /// The matching qualifier kind. + /// when the key is known. + private static bool TryLookupQualifier(string key, out QualifierKind kind) + { + foreach ((string candidate, QualifierKind candidateKind) in QualifierTable) + { + if (string.Equals(candidate, key, StringComparison.Ordinal)) + { + kind = candidateKind; + return true; + } + } + + kind = default; + return false; + } + + /// + /// Returns whether the text looks like a qualifier key: a non-empty run of + /// letters, digits or underscores. + /// + /// The candidate key text. + /// when every character is a word character. + private static bool IsQualifierKeyCandidate(ReadOnlySpan text) + { + foreach (char character in text) + { + if (!(char.IsLetterOrDigit(character) || character == '_')) + { + return false; + } + } + + return true; + } + + /// + /// Collapses an AND chain into a single node. + /// + /// The first operand. + /// The remaining operands. + /// The single operand, or an 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. + /// + /// The operands. + /// The single operand, or a node. + private static SearchNode FoldOr(IEnumerable terms) + { + List list = terms.ToList(); + return list.Count == 1 ? list[0] : new SearchNode.Or(list); + } + + #endregion + + /// The recognized qualifier kinds. + private enum QualifierKind + { + Tag, + Title, + Content, + Id, + Column, + } +} diff --git a/YKanBan/Search/SearchSqlCompiler.cs b/YKanBan/Search/SearchSqlCompiler.cs new file mode 100644 index 0000000..95530fe --- /dev/null +++ b/YKanBan/Search/SearchSqlCompiler.cs @@ -0,0 +1,107 @@ +namespace YKanBan.Search; + +/// +/// A compiled search predicate: a SQL fragment over the "c" (cards) alias plus +/// its bound parameters. +/// +/// SQL boolean expression, 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 semantics: LIKE's default case +/// folding for substring matches 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. + /// + /// The parsed search AST. + /// The compiled predicate and its parameters. + 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. + /// + /// The AST node to compile. + /// The parameter accumulator. + /// The SQL boolean expression for the 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. + /// + /// The parameter accumulator. + /// The value to bind. + /// The placeholder text. + 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. + /// + /// The raw user text. + /// The text with \, % and _ escaped. + private static string EscapeLike(string text) => + text.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_"); +} diff --git a/YKanBan/Search/SearchSyntaxException.cs b/YKanBan/Search/SearchSyntaxException.cs new file mode 100644 index 0000000..483d89a --- /dev/null +++ b/YKanBan/Search/SearchSyntaxException.cs @@ -0,0 +1,20 @@ +namespace YKanBan.Search; + +/// +/// Thrown when the search text violates the search grammar. Carries the +/// underlying parser's descriptive English message; the UI surfaces it as a +/// search error instead of coupling to Pidgin types. The message is never +/// localized. +/// +public sealed class SearchSyntaxException : Exception +{ + /// + /// Initializes the exception with a descriptive English message. + /// + /// The English diagnostic message. + /// The underlying parse failure. + public SearchSyntaxException(string message, Exception innerException) + : base(message, innerException) + { + } +}