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)
+ {
+ }
+}