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