diff --git a/YKanBan.Tests/Launching/LaunchArgumentsTests.cs b/YKanBan.Tests/Launching/LaunchArgumentsTests.cs
new file mode 100644
index 0000000..3413b64
--- /dev/null
+++ b/YKanBan.Tests/Launching/LaunchArgumentsTests.cs
@@ -0,0 +1,40 @@
+using YKanBan.Launching;
+
+namespace YKanBan.Tests.Launching;
+
+///
+/// Tests for the ykanban <path> command-line contract across its
+/// three branches: no argument, one valid path, and invalid input.
+///
+[TestClass]
+public class LaunchArgumentsTests
+{
+ [TestMethod]
+ public void NoArgumentsIsRejected()
+ {
+ Assert.IsFalse(LaunchArguments.TryParse([], out string? path));
+ Assert.IsNull(path);
+ }
+
+ [TestMethod]
+ public void BlankArgumentIsRejected()
+ {
+ Assert.IsFalse(LaunchArguments.TryParse([" "], out string? path));
+ Assert.IsNull(path);
+ }
+
+ [TestMethod]
+ public void MoreThanOneArgumentIsRejected()
+ {
+ Assert.IsFalse(LaunchArguments.TryParse(["a", "b"], out string? path));
+ Assert.IsNull(path);
+ }
+
+ [TestMethod]
+ public void SinglePathIsAccepted()
+ {
+ bool parsed = LaunchArguments.TryParse([@"C:\Repo\MyProject"], out string? path);
+ Assert.IsTrue(parsed);
+ Assert.AreEqual(@"C:\Repo\MyProject", path);
+ }
+}
diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
new file mode 100644
index 0000000..55b1915
--- /dev/null
+++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
@@ -0,0 +1,161 @@
+using YKanBan.Storage.AppData;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.AppData;
+
+///
+/// Tests for app.json round-tripping, kebab-case naming and graceful
+/// degradation on a missing or corrupt file.
+///
+[TestClass]
+public class AppConfigStoreTests
+{
+ [TestMethod]
+ public void LoadMissingFileReturnsDefaults()
+ {
+ using var directory = new TempDirectory();
+ var store = new AppConfigStore(Path.Combine(directory.FullPath, "app.json"));
+
+ AppConfig config = store.Load();
+
+ Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version);
+ Assert.AreEqual(AppConfig.DefaultLanguage, config.Language);
+ Assert.AreEqual(ThemeOption.FollowSystem, config.Theme);
+ Assert.AreEqual(CardSortOption.Id, config.Sort.Card);
+ Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset);
+ Assert.AreEqual(320, config.ColumnWidth.CustomPixels);
+ Assert.IsTrue(config.Confirmations.DeleteCard);
+ Assert.IsTrue(config.Confirmations.DeleteColumn);
+ Assert.IsTrue(config.Confirmations.DeleteTag);
+ Assert.IsTrue(config.Confirmations.DiscardEdit);
+ }
+
+ [TestMethod]
+ public void LoadCorruptFileBacksUpAndReturnsDefaults()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ File.WriteAllText(path, "{ this is not valid json");
+ var store = new AppConfigStore(path);
+
+ AppConfig config = store.Load();
+
+ Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version);
+ Assert.IsTrue(File.Exists(path + ".bak"));
+ Assert.AreEqual("{ this is not valid json", File.ReadAllText(path + ".bak"));
+ }
+
+ [TestMethod]
+ public void LoadJsonNullReturnsDefaults()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ File.WriteAllText(path, "null");
+ var store = new AppConfigStore(path);
+
+ Assert.AreEqual(AppConfig.CurrentFormatVersion, store.Load().Version);
+ }
+
+ [TestMethod]
+ public void LoadReadsKebabCaseKeysAndEnumValues()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ File.WriteAllText(path, $$"""
+ {
+ "version": {{AppConfig.CurrentFormatVersion}},
+ "language": "zh-Hans",
+ "theme": "dark",
+ "sort": { "card": "title" },
+ "column-width": { "preset": "custom", "custom-pixels": 400 },
+ "confirmations": {
+ "delete-card": false,
+ "delete-column": false,
+ "delete-tag": false,
+ "discard-edit": false
+ }
+ }
+ """);
+ var store = new AppConfigStore(path);
+
+ AppConfig config = store.Load();
+
+ Assert.AreEqual("zh-Hans", config.Language);
+ Assert.AreEqual(ThemeOption.Dark, config.Theme);
+ Assert.AreEqual(CardSortOption.Title, config.Sort.Card);
+ Assert.AreEqual(ColumnWidthPreset.Custom, config.ColumnWidth.Preset);
+ Assert.AreEqual(400, config.ColumnWidth.CustomPixels);
+ Assert.IsFalse(config.Confirmations.DeleteCard);
+ Assert.IsFalse(config.Confirmations.DeleteColumn);
+ Assert.IsFalse(config.Confirmations.DeleteTag);
+ Assert.IsFalse(config.Confirmations.DiscardEdit);
+ }
+
+ [TestMethod]
+ public void LoadMissingSectionsFillsDefaults()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ File.WriteAllText(path, $$"""{ "version": {{AppConfig.CurrentFormatVersion}} }""");
+ var store = new AppConfigStore(path);
+
+ AppConfig config = store.Load();
+
+ Assert.IsNotNull(config.Sort);
+ Assert.IsNotNull(config.ColumnWidth);
+ Assert.IsNotNull(config.Confirmations);
+ Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset);
+ }
+
+ [TestMethod]
+ public void SaveWritesKebabCaseKeysAndEnumValues()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ var store = new AppConfigStore(path);
+
+ var config = new AppConfig
+ {
+ Theme = ThemeOption.FollowSystem,
+ Sort = new SortSettings { Card = CardSortOption.UpdatedAt },
+ ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Custom, CustomPixels = 500 },
+ Confirmations = new ConfirmationSettings { DeleteColumn = false },
+ };
+ store.Save(config);
+
+ string json = File.ReadAllText(path);
+ StringAssert.Contains(json, "\"delete-column\"");
+ StringAssert.Contains(json, "\"discard-edit\"");
+ StringAssert.Contains(json, "\"custom-pixels\"");
+ StringAssert.Contains(json, "\"follow-system\"");
+ StringAssert.Contains(json, "\"updated-at\"");
+ // No member-name CamelCase must leak into the file.
+ Assert.IsFalse(json.Contains("deleteColumn", StringComparison.Ordinal));
+ Assert.IsFalse(json.Contains("CustomPixels", StringComparison.Ordinal));
+ }
+
+ [TestMethod]
+ public void SaveThenLoadRoundTrips()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ var store = new AppConfigStore(path);
+
+ var config = new AppConfig
+ {
+ Language = "zh-Hans",
+ Theme = ThemeOption.Light,
+ Sort = new SortSettings { Card = CardSortOption.CreatedAt },
+ ColumnWidth = new ColumnWidthSettings { Preset = ColumnWidthPreset.Wide },
+ Confirmations = new ConfirmationSettings { DiscardEdit = false },
+ };
+ store.Save(config);
+
+ AppConfig reloaded = store.Load();
+ Assert.AreEqual(config.Language, reloaded.Language);
+ Assert.AreEqual(config.Theme, reloaded.Theme);
+ Assert.AreEqual(config.Sort.Card, reloaded.Sort.Card);
+ Assert.AreEqual(config.ColumnWidth.Preset, reloaded.ColumnWidth.Preset);
+ Assert.AreEqual(config.Confirmations.DiscardEdit, reloaded.Confirmations.DiscardEdit);
+ }
+}
diff --git a/YKanBan.Tests/Storage/SqliteDatabaseTests.cs b/YKanBan.Tests/Storage/SqliteDatabaseTests.cs
new file mode 100644
index 0000000..1bf3be8
--- /dev/null
+++ b/YKanBan.Tests/Storage/SqliteDatabaseTests.cs
@@ -0,0 +1,83 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage;
+
+///
+/// Tests for the shared SQLite plumbing: mandatory PRAGMAs and the
+/// user_version-driven migration runner.
+///
+[TestClass]
+public class SqliteDatabaseTests
+{
+ [TestMethod]
+ public void OpenAppliesMandatoryPragmas()
+ {
+ using var directory = new TempDirectory();
+ string databasePath = Path.Combine(directory.FullPath, "pragmas.db");
+
+ using SqliteConnection connection = SqliteDatabase.Open(databasePath, []);
+ Assert.AreEqual("wal", SqliteTestHelper.ScalarString(connection, "PRAGMA journal_mode;"));
+ Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(connection, "PRAGMA synchronous;"));
+ Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(connection, "PRAGMA foreign_keys;"));
+ }
+
+ [TestMethod]
+ public void OpenSetsUserVersionToLatestMigration()
+ {
+ using var directory = new TempDirectory();
+ string databasePath = Path.Combine(directory.FullPath, "version.db");
+
+ using SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations);
+ Assert.AreEqual(WorkspaceSchema.CurrentVersion, SqliteDatabase.ReadUserVersion(connection));
+ }
+
+ [TestMethod]
+ public void ReopenKeepsWalJournalMode()
+ {
+ using var directory = new TempDirectory();
+ string databasePath = Path.Combine(directory.FullPath, "wal.db");
+
+ using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations))
+ {
+ }
+
+ // WAL mode is stored in the database header, so a fresh connection keeps it.
+ using SqliteConnection reopened = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations);
+ Assert.AreEqual("wal", SqliteTestHelper.ScalarString(reopened, "PRAGMA journal_mode;"));
+ }
+
+ [TestMethod]
+ public void OpenRejectsNewerStoredSchemaVersion()
+ {
+ using var directory = new TempDirectory();
+ string databasePath = Path.Combine(directory.FullPath, "newer.db");
+
+ long newerVersion = WorkspaceSchema.CurrentVersion + 1;
+ using (SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations))
+ {
+ SqliteTestHelper.Exec(connection, $"PRAGMA user_version={newerVersion};");
+ }
+
+ SchemaVersionException exception = Assert.ThrowsExactly(
+ () => SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations));
+ Assert.AreEqual(newerVersion, exception.StoredVersion);
+ Assert.AreEqual(WorkspaceSchema.CurrentVersion, exception.SupportedVersion);
+ }
+
+ [TestMethod]
+ public void OpenRejectsNonContiguousMigrationList()
+ {
+ using var directory = new TempDirectory();
+ string databasePath = Path.Combine(directory.FullPath, "gap.db");
+
+ SchemaMigration[] broken =
+ [
+ new SchemaMigration(2, "starts too high", "SELECT 1;"),
+ ];
+
+ Assert.ThrowsExactly(() => SqliteDatabase.Open(databasePath, broken));
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs
new file mode 100644
index 0000000..515cd7b
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs
@@ -0,0 +1,64 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+///
+/// Tests for workspace detection and initialization. Initialization creates
+/// the structure and an empty migrated database, but deliberately adds no
+/// content.
+///
+[TestClass]
+public class WorkspaceInitializerTests
+{
+ [TestMethod]
+ public void IsWorkspaceReflectsInitializationState()
+ {
+ using var directory = new TempDirectory();
+ Assert.IsFalse(WorkspaceInitializer.IsWorkspace(directory.FullPath));
+
+ WorkspaceInitializer.Initialize(directory.FullPath);
+ Assert.IsTrue(WorkspaceInitializer.IsWorkspace(directory.FullPath));
+ }
+
+ [TestMethod]
+ public void InitializeCreatesStructureAndMigratedEmptyDatabase()
+ {
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+
+ Assert.IsTrue(Directory.Exists(WorkspacePaths.Root(directory.FullPath)));
+ Assert.IsTrue(File.Exists(WorkspacePaths.Database(directory.FullPath)));
+
+ using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
+ Assert.AreEqual(WorkspaceSchema.CurrentVersion, SqliteDatabase.ReadUserVersion(connection));
+ Assert.IsTrue(SqliteTestHelper.TableExists(connection, "columns"));
+ Assert.IsTrue(SqliteTestHelper.TableExists(connection, "cards"));
+ Assert.IsTrue(SqliteTestHelper.TableExists(connection, "tags"));
+ Assert.IsTrue(SqliteTestHelper.TableExists(connection, "card_tags"));
+ // Initialization does not add preset content.
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM columns;"));
+ }
+
+ [TestMethod]
+ public void InitializeMissingFolderThrows()
+ {
+ using var directory = new TempDirectory();
+ string missing = Path.Combine(directory.FullPath, "not-created");
+
+ WorkspaceDirectoryMissingException exception = Assert.ThrowsExactly(
+ () => WorkspaceInitializer.Initialize(missing));
+ Assert.AreEqual(missing, exception.FolderPath);
+ }
+
+ [TestMethod]
+ public void InitializeTwiceThrows()
+ {
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+
+ Assert.ThrowsExactly(() => WorkspaceInitializer.Initialize(directory.FullPath));
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs
new file mode 100644
index 0000000..5806c23
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs
@@ -0,0 +1,59 @@
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+///
+/// Tests for exclusive lock acquisition, diagnostics and release.
+///
+[TestClass]
+public class WorkspaceLockTests
+{
+ [TestMethod]
+ public void AcquireThenDisposeThenAcquireAgainSucceeds()
+ {
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+
+ WorkspaceLock.Acquire(directory.FullPath).Dispose();
+ // Re-acquiring after release works: no stale lock residue survives.
+ WorkspaceLock.Acquire(directory.FullPath).Dispose();
+ }
+
+ [TestMethod]
+ public void SecondAcquireWhileHeldThrows()
+ {
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+
+ using WorkspaceLock first = WorkspaceLock.Acquire(directory.FullPath);
+ WorkspaceLockException exception = Assert.ThrowsExactly(
+ () => WorkspaceLock.Acquire(directory.FullPath));
+ Assert.AreEqual(WorkspacePaths.LockFile(directory.FullPath), exception.LockFilePath);
+ }
+
+ [TestMethod]
+ public void AcquireWithoutYkanbanThrowsNotInitialized()
+ {
+ using var directory = new TempDirectory();
+ Assert.ThrowsExactly(
+ () => WorkspaceLock.Acquire(directory.FullPath));
+ }
+
+ [TestMethod]
+ public void LockFileContainsHolderDiagnosticsAfterRelease()
+ {
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+
+ using (WorkspaceLock.Acquire(directory.FullPath))
+ {
+ }
+
+ // The file survives release and carries PID / machine name / time for humans.
+ string content = File.ReadAllText(WorkspacePaths.LockFile(directory.FullPath));
+ StringAssert.Contains(content, "pid=");
+ StringAssert.Contains(content, $"machine={Environment.MachineName}");
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs
new file mode 100644
index 0000000..813ddc7
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs
@@ -0,0 +1,99 @@
+using System.Globalization;
+using Microsoft.Data.Sqlite;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+///
+/// Verifies that the preset column titles follow the UI language active when
+/// they are added and then live as ordinary data. These tests mutate the
+/// global , so they are marked do-not-parallelize.
+///
+[TestClass]
+[DoNotParallelize]
+public class WorkspacePresetTests
+{
+ [TestMethod]
+ public void AddPresetColumnsStoresEnglishTitlesUnderEnglishCulture()
+ {
+ CultureInfo? original = Resources.Culture;
+ try
+ {
+ Resources.Culture = new CultureInfo("en");
+ string[] expected =
+ [
+ Resources.PresetColumn_Todo,
+ Resources.PresetColumn_InProgress,
+ Resources.PresetColumn_Done,
+ ];
+
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+ WorkspacePreset.AddPresetColumns(directory.FullPath);
+
+ using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
+ CollectionAssert.AreEqual(expected, SqliteTestHelper.GetColumnTitles(connection));
+ }
+ finally
+ {
+ Resources.Culture = original;
+ }
+ }
+
+ [TestMethod]
+ public void AddPresetColumnsStoresChineseTitlesUnderChineseCulture()
+ {
+ CultureInfo? original = Resources.Culture;
+ try
+ {
+ Resources.Culture = new CultureInfo("zh-Hans");
+ string[] expected =
+ [
+ Resources.PresetColumn_Todo,
+ Resources.PresetColumn_InProgress,
+ Resources.PresetColumn_Done,
+ ];
+
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+ WorkspacePreset.AddPresetColumns(directory.FullPath);
+
+ using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
+ CollectionAssert.AreEqual(expected, SqliteTestHelper.GetColumnTitles(connection));
+ }
+ finally
+ {
+ Resources.Culture = original;
+ }
+ }
+
+ [TestMethod]
+ public void PresetTitlesAreDataAndIgnoreLaterCultureChanges()
+ {
+ CultureInfo? original = Resources.Culture;
+ try
+ {
+ Resources.Culture = new CultureInfo("en");
+ string[] expected =
+ [
+ Resources.PresetColumn_Todo,
+ Resources.PresetColumn_InProgress,
+ Resources.PresetColumn_Done,
+ ];
+
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+ WorkspacePreset.AddPresetColumns(directory.FullPath);
+
+ // Switching the language after adding the presets must not rewrite stored data.
+ Resources.Culture = new CultureInfo("zh-Hans");
+ using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
+ CollectionAssert.AreEqual(expected, SqliteTestHelper.GetColumnTitles(connection));
+ }
+ finally
+ {
+ Resources.Culture = original;
+ }
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs
new file mode 100644
index 0000000..7e2d5b1
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs
@@ -0,0 +1,224 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+///
+/// Verifies that the workspace DDL constraints reject invalid data at the door
+/// and that the cascading delete chains work as specified.
+///
+[TestClass]
+public class WorkspaceSchemaTests
+{
+ ///
+ /// Owns a temp directory plus a fresh workspace-schema database for a single test.
+ ///
+ private sealed class FreshDatabase : IDisposable
+ {
+ public TempDirectory Directory { get; } = new();
+
+ public SqliteConnection Connection { get; }
+
+ public FreshDatabase()
+ {
+ Connection = SqliteDatabase.Open(
+ Path.Combine(Directory.FullPath, "ws.db"), WorkspaceSchema.Migrations);
+ }
+
+ ///
+ /// Closes the connection before removing the temporary directory.
+ ///
+ public void Dispose()
+ {
+ Connection.Dispose();
+ Directory.Dispose();
+ }
+ }
+
+ [TestMethod]
+ public void EmptyColumnTitleIsRejected()
+ {
+ using var db = new FreshDatabase();
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('', 1, 1);"));
+ }
+
+ [TestMethod]
+ public void WhitespaceColumnTitleIsAccepted()
+ {
+ using var db = new FreshDatabase();
+ // No trimming: a single space is a non-empty title as far as the DDL is concerned.
+ int affected = SqliteTestHelper.Exec(
+ db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES (' ', 1, 1);");
+ Assert.AreEqual(1, affected);
+ }
+
+ [TestMethod]
+ public void DuplicateColumnTitleIsRejected()
+ {
+ using var db = new FreshDatabase();
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);");
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);"));
+ }
+
+ [TestMethod]
+ public void ColumnTitlesCompareCaseSensitively()
+ {
+ using var db = new FreshDatabase();
+ // BINARY collation: 'Foo' and 'foo' are distinct titles.
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);");
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('foo', 1, 1);");
+ Assert.AreEqual(2L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM columns;"));
+ }
+
+ [TestMethod]
+ public void EmptyTagNameIsRejected()
+ {
+ using var db = new FreshDatabase();
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('', '#112233');"));
+ }
+
+ [TestMethod]
+ public void TagNamesAcceptSpacesEmojiAndReservedWords()
+ {
+ using var db = new FreshDatabase();
+ // Tag names are fully open: no charset or length rules, no trimming.
+ foreach (string name in new[] { " ", "a b", "🏷️", "AND", "tag:with:colons" })
+ {
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ($n, '#112233');", ("$n", name));
+ }
+ Assert.AreEqual(5L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;"));
+ }
+
+ [TestMethod]
+ public void DuplicateTagNameIsRejected()
+ {
+ using var db = new FreshDatabase();
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');");
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('bug', '#445566');"));
+ }
+
+ [TestMethod]
+ [DataRow("#000000")]
+ [DataRow("#FFFFFF")]
+ [DataRow("#a1B2c3")]
+ public void ValidTagColorsAreAccepted(string color)
+ {
+ using var db = new FreshDatabase();
+ int affected = SqliteTestHelper.Exec(
+ db.Connection, "INSERT INTO tags (name, color) VALUES ('x', $c);", ("$c", color));
+ Assert.AreEqual(1, affected);
+ }
+
+ [TestMethod]
+ [DataRow("112233")]
+ [DataRow("#11223")]
+ [DataRow("#1122334")]
+ [DataRow("#G1B2C3")]
+ [DataRow("#a1b2g3")]
+ [DataRow(" #112233")]
+ [DataRow("#112233 ")]
+ [DataRow("")]
+ public void InvalidTagColorsAreRejected(string color)
+ {
+ using var db = new FreshDatabase();
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('x', $c);", ("$c", color)));
+ }
+
+ [TestMethod]
+ public void CardRequiresExistingColumn()
+ {
+ using var db = new FreshDatabase();
+ // foreign_keys=ON makes the FK enforceable; this is the DDL contract behind the cascades.
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (999, 1, 1);"));
+ }
+
+ [TestMethod]
+ public void CardDefaultsToEmptyTitleAndContent()
+ {
+ using var db = new FreshDatabase();
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);");
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (1, 1, 1);");
+
+ // No title is represented by the empty string.
+ Assert.AreEqual(string.Empty, SqliteTestHelper.ScalarString(db.Connection, "SELECT title FROM cards WHERE id = 1;"));
+ Assert.AreEqual(string.Empty, SqliteTestHelper.ScalarString(db.Connection, "SELECT content FROM cards WHERE id = 1;"));
+ }
+
+ [TestMethod]
+ public void CardTagDuplicateAssignmentIsRejected()
+ {
+ using var db = new FreshDatabase();
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);");
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (1, 1, 1);");
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');");
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);");
+
+ // Composite primary key: the same (card, tag) pair cannot be inserted twice.
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(db.Connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);"));
+ }
+
+ [TestMethod]
+ public void DeletingColumnCascadesToCardsAndCardTags()
+ {
+ using var db = new FreshDatabase();
+ InsertBoardRow(db.Connection);
+
+ SqliteTestHelper.Exec(db.Connection, "DELETE FROM columns WHERE id = 1;");
+
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM columns;"));
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM cards;"));
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM card_tags;"));
+ // Tags are never cleaned up automatically.
+ Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;"));
+ }
+
+ [TestMethod]
+ public void DeletingCardCascadesToCardTagsOnly()
+ {
+ using var db = new FreshDatabase();
+ InsertBoardRow(db.Connection);
+
+ SqliteTestHelper.Exec(db.Connection, "DELETE FROM cards WHERE id = 1;");
+
+ Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM columns;"));
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM cards;"));
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM card_tags;"));
+ Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;"));
+ }
+
+ [TestMethod]
+ public void DeletingTagRemovesAssignmentsButKeepsCards()
+ {
+ using var db = new FreshDatabase();
+ InsertBoardRow(db.Connection);
+
+ SqliteTestHelper.Exec(db.Connection, "DELETE FROM tags WHERE id = 1;");
+
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM tags;"));
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM card_tags;"));
+ // The card merely loses the tag.
+ Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM cards;"));
+ Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(db.Connection, "SELECT COUNT(*) FROM columns;"));
+ }
+
+ ///
+ /// Inserts one column, one card, one tag and their assignment, ready for a cascade test.
+ ///
+ /// An open workspace-schema connection.
+ private static void InsertBoardRow(SqliteConnection connection)
+ {
+ SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('c', 1, 1);");
+ SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (1, 1, 1);");
+ SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');");
+ SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);");
+ }
+}
diff --git a/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs b/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs
new file mode 100644
index 0000000..f7ad51f
--- /dev/null
+++ b/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs
@@ -0,0 +1,99 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+
+namespace YKanBan.Tests.TestUtilities;
+
+///
+/// Shared helpers for the storage tests: small SQL conveniences plus opening a
+/// workspace database with the real migration list.
+///
+public static class SqliteTestHelper
+{
+ ///
+ /// Executes a non-query SQL statement and returns the affected row count.
+ ///
+ /// An open database connection.
+ /// The statement to execute.
+ /// Name/value pairs bound as SQL parameters.
+ /// The number of affected rows.
+ public static int Exec(SqliteConnection connection, string sql, params (string Name, object? Value)[] parameters)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ foreach ((string name, object? value) in parameters)
+ {
+ command.Parameters.AddWithValue(name, value);
+ }
+ return command.ExecuteNonQuery();
+ }
+
+ ///
+ /// Returns the scalar result of a SQL query as a string.
+ ///
+ /// An open database connection.
+ /// The query to execute.
+ /// The scalar result cast to a string.
+ public static string ScalarString(SqliteConnection connection, string sql)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ return (string)command.ExecuteScalar()!;
+ }
+
+ ///
+ /// Returns the scalar result of a SQL query as a long.
+ ///
+ /// An open database connection.
+ /// The query to execute.
+ /// The scalar result cast to a long.
+ public static long ScalarLong(SqliteConnection connection, string sql)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ return (long)command.ExecuteScalar()!;
+ }
+
+ ///
+ /// Returns whether a table with the given name exists in the database.
+ ///
+ /// An open database connection.
+ /// The table name to look for.
+ /// when the table exists.
+ public static bool TableExists(SqliteConnection connection, string tableName)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$name;";
+ command.Parameters.AddWithValue("$name", tableName);
+ return (long)command.ExecuteScalar()! == 1;
+ }
+
+ ///
+ /// Returns the titles of all columns, ordered by id ascending.
+ ///
+ /// An open database connection.
+ /// The column titles in id order.
+ public static List GetColumnTitles(SqliteConnection connection)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = "SELECT title FROM columns ORDER BY id ASC;";
+ using var reader = command.ExecuteReader();
+
+ var titles = new List();
+ while (reader.Read())
+ {
+ titles.Add(reader.GetString(0));
+ }
+ return titles;
+ }
+
+ ///
+ /// Opens a workspace database with the real workspace migration list.
+ ///
+ /// The workspace folder path.
+ /// An open, migrated connection.
+ public static SqliteConnection OpenWorkspace(string folderPath)
+ {
+ return SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
+ }
+}
diff --git a/YKanBan.Tests/TestUtilities/TempDirectory.cs b/YKanBan.Tests/TestUtilities/TempDirectory.cs
new file mode 100644
index 0000000..e8017a2
--- /dev/null
+++ b/YKanBan.Tests/TestUtilities/TempDirectory.cs
@@ -0,0 +1,43 @@
+namespace YKanBan.Tests.TestUtilities;
+
+///
+/// Creates a unique temporary directory per test instance and removes it on
+/// disposal. The removal is retried once because SQLite WAL sidecar files can
+/// linger for a moment after the connections close.
+///
+public sealed class TempDirectory : IDisposable
+{
+ ///
+ /// Gets the unique directory path managed by this instance.
+ ///
+ public string FullPath { get; }
+
+ public TempDirectory()
+ {
+ FullPath = Path.Combine(Path.GetTempPath(), "ykanban-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(FullPath);
+ }
+
+ ///
+ /// Deletes the directory tree, retrying once after a short delay.
+ ///
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(FullPath, recursive: true);
+ }
+ catch (IOException)
+ {
+ Thread.Sleep(100);
+ try
+ {
+ Directory.Delete(FullPath, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Leftovers in the OS temp folder are harmless; never fail a test over cleanup.
+ }
+ }
+ }
+}
diff --git a/YKanBan.Tests/YKanBan.Tests.csproj b/YKanBan.Tests/YKanBan.Tests.csproj
index 8e7c5a6..14553d9 100644
--- a/YKanBan.Tests/YKanBan.Tests.csproj
+++ b/YKanBan.Tests/YKanBan.Tests.csproj
@@ -2,6 +2,7 @@
net9.0
+ enable
enable
false
true
diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx
new file mode 100644
index 0000000..8e09503
--- /dev/null
+++ b/YKanBan/Assets/Locales/Resources.resx
@@ -0,0 +1,28 @@
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ To Do
+
+
+ In Progress
+
+
+ Done
+
+
diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx
new file mode 100644
index 0000000..6eba378
--- /dev/null
+++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx
@@ -0,0 +1,28 @@
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ 待办
+
+
+ 进行中
+
+
+ 已完成
+
+
diff --git a/YKanBan/Launching/LaunchArguments.cs b/YKanBan/Launching/LaunchArguments.cs
new file mode 100644
index 0000000..e96d3e6
--- /dev/null
+++ b/YKanBan/Launching/LaunchArguments.cs
@@ -0,0 +1,31 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace YKanBan.Launching;
+
+///
+/// Parses raw command-line arguments. The only accepted form is a single,
+/// non-blank workspace path (ykanban <path>); zero arguments,
+/// more than one argument, or a blank argument is rejected as an argument
+/// error. The path itself is not checked for existence here.
+///
+public static class LaunchArguments
+{
+ ///
+ /// Tries to parse command-line arguments following the
+ /// ykanban <path> contract.
+ ///
+ /// The raw arguments, excluding the executable name.
+ /// The parsed workspace path on success, otherwise .
+ /// when exactly one non-blank path was supplied.
+ public static bool TryParse(IReadOnlyList arguments, [NotNullWhen(true)] out string? folderPath)
+ {
+ if (arguments.Count == 1 && !string.IsNullOrWhiteSpace(arguments[0]))
+ {
+ folderPath = arguments[0];
+ return true;
+ }
+
+ folderPath = null;
+ return false;
+ }
+}
diff --git a/YKanBan/Storage/AppData/AppConfig.cs b/YKanBan/Storage/AppData/AppConfig.cs
new file mode 100644
index 0000000..4b1ed52
--- /dev/null
+++ b/YKanBan/Storage/AppData/AppConfig.cs
@@ -0,0 +1,183 @@
+using System.Runtime.Serialization;
+using Newtonsoft.Json;
+
+namespace YKanBan.Storage.AppData;
+
+///
+/// Card ordering options. Every order falls back to id ascending as the final
+/// tie-breaker.
+///
+public enum CardSortOption
+{
+ /// Primary key ascending (default).
+ [EnumMember(Value = "id")]
+ Id,
+
+ /// Creation time, oldest first.
+ [EnumMember(Value = "created-at")]
+ CreatedAt,
+
+ /// Last modification time, most recently modified first.
+ [EnumMember(Value = "updated-at")]
+ UpdatedAt,
+
+ /// Title by code points (BINARY collation); empty titles sort first.
+ [EnumMember(Value = "title")]
+ Title,
+}
+
+///
+/// Column width presets. uses a user-defined pixel width.
+///
+public enum ColumnWidthPreset
+{
+ /// Narrow preset.
+ [EnumMember(Value = "narrow")]
+ Narrow,
+
+ /// Standard preset (320 px, the default).
+ [EnumMember(Value = "standard")]
+ Standard,
+
+ /// Wide preset.
+ [EnumMember(Value = "wide")]
+ Wide,
+
+ /// User-defined pixel width (160–720).
+ [EnumMember(Value = "custom")]
+ Custom,
+}
+
+///
+/// UI theme selection.
+///
+public enum ThemeOption
+{
+ /// Always light.
+ [EnumMember(Value = "light")]
+ Light,
+
+ /// Always dark.
+ [EnumMember(Value = "dark")]
+ Dark,
+
+ /// Follow the operating system setting (default).
+ [EnumMember(Value = "follow-system")]
+ FollowSystem,
+}
+
+///
+/// Card ordering settings. The chosen order is global, not per workspace.
+///
+public sealed class SortSettings
+{
+ ///
+ /// Gets or sets the order used by the board.
+ ///
+ [JsonProperty("card")]
+ public CardSortOption Card { get; set; } = CardSortOption.Id;
+}
+
+///
+/// Board column width settings, applied globally.
+///
+public sealed class ColumnWidthSettings
+{
+ ///
+ /// Gets or sets the active width preset.
+ ///
+ [JsonProperty("preset")]
+ public ColumnWidthPreset Preset { get; set; } = ColumnWidthPreset.Standard;
+
+ ///
+ /// Gets or sets the manual width in pixels, used when
+ /// is (range 160–720).
+ ///
+ [JsonProperty("custom-pixels")]
+ public int CustomPixels { get; set; } = 320;
+}
+
+///
+/// Confirmation-dialog toggles for the destructive actions.
+///
+public sealed class ConfirmationSettings
+{
+ ///
+ /// Gets or sets whether deleting a card asks for confirmation.
+ ///
+ [JsonProperty("delete-card")]
+ public bool DeleteCard { get; set; } = true;
+
+ ///
+ /// Gets or sets whether deleting a column asks for confirmation.
+ ///
+ [JsonProperty("delete-column")]
+ public bool DeleteColumn { get; set; } = true;
+
+ ///
+ /// Gets or sets whether deleting a tag asks for confirmation.
+ ///
+ [JsonProperty("delete-tag")]
+ public bool DeleteTag { get; set; } = true;
+
+ ///
+ /// Gets or sets whether discarding card edits asks for confirmation.
+ ///
+ [JsonProperty("discard-edit")]
+ public bool DiscardEdit { get; set; } = true;
+}
+
+///
+/// Root object of app.json, the whole-file key/value configuration store.
+/// Keys and enum values are serialized in kebab-case (declared explicitly via
+/// and ).
+/// The version field is the migration hook for future format changes.
+///
+public sealed class AppConfig
+{
+ ///
+ /// Current configuration format version.
+ ///
+ public const int CurrentFormatVersion = 1;
+
+ ///
+ /// Default UI language code.
+ ///
+ public const string DefaultLanguage = "en";
+
+ ///
+ /// Gets or sets the configuration format version of this file.
+ ///
+ [JsonProperty("version")]
+ public int Version { get; set; } = CurrentFormatVersion;
+
+ ///
+ /// Gets or sets the UI language code ("en" default or "zh-Hans").
+ ///
+ [JsonProperty("language")]
+ public string Language { get; set; } = DefaultLanguage;
+
+ ///
+ /// Gets or sets the UI theme selection (default: follow the system).
+ ///
+ [JsonProperty("theme")]
+ public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem;
+
+ ///
+ /// Gets or sets the card ordering settings.
+ ///
+ [JsonProperty("sort")]
+ public SortSettings Sort { get; set; } = new();
+
+ ///
+ /// Gets or sets the board column width settings.
+ ///
+ [JsonProperty("column-width")]
+ public ColumnWidthSettings ColumnWidth { get; set; } = new();
+
+ ///
+ /// Gets or sets the confirmation-dialog settings.
+ ///
+ [JsonProperty("confirmations")]
+ public ConfirmationSettings Confirmations { get; set; } = new();
+}
diff --git a/YKanBan/Storage/AppData/AppConfigStore.cs b/YKanBan/Storage/AppData/AppConfigStore.cs
new file mode 100644
index 0000000..97e10ab
--- /dev/null
+++ b/YKanBan/Storage/AppData/AppConfigStore.cs
@@ -0,0 +1,87 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+
+namespace YKanBan.Storage.AppData;
+
+///
+/// Loads and saves app.json with graceful degradation: a missing file yields
+/// defaults; a corrupt file is backed up to app.json.bak and then defaults are
+/// used. Saving writes the file directly (no atomic replace) — losing the last
+/// session's settings to a crash is an accepted trade-off.
+///
+public sealed class AppConfigStore
+{
+ private static readonly JsonSerializerSettings SerializerSettings = new()
+ {
+ Formatting = Formatting.Indented,
+ NullValueHandling = NullValueHandling.Ignore,
+
+ // Enums round-trip through the kebab-case strings declared via [EnumMember].
+ Converters = { new StringEnumConverter() },
+ };
+
+ ///
+ /// Initializes a store bound to a specific app.json path.
+ ///
+ /// Full path of the app.json file to manage.
+ public AppConfigStore(string filePath) => FilePath = filePath;
+
+ ///
+ /// Gets the full path of the managed app.json file.
+ ///
+ public string FilePath { get; }
+
+ ///
+ /// Loads the configuration. A missing file yields defaults; a corrupt file
+ /// is backed up to app.json.bak and then defaults are used.
+ ///
+ /// The loaded configuration, or defaults when none could be read.
+ /// The file exists but cannot be read.
+ public AppConfig Load()
+ {
+ // Missing file is a normal first-run state: run with defaults.
+ if (!File.Exists(FilePath))
+ {
+ return new AppConfig();
+ }
+
+ string json = File.ReadAllText(FilePath);
+ try
+ {
+ // A JSON null literal deserializes to null; treat it like defaults.
+ AppConfig? config = JsonConvert.DeserializeObject(json, SerializerSettings);
+ if (config is null)
+ {
+ return new AppConfig();
+ }
+
+ // Older or hand-edited files may miss whole sections; each falls back to its defaults.
+ config.Sort ??= new SortSettings();
+ config.ColumnWidth ??= new ColumnWidthSettings();
+ config.Confirmations ??= new ConfirmationSettings();
+ return config;
+ }
+ catch (JsonException)
+ {
+ // Corrupt: keep a copy for inspection, then continue with defaults.
+ File.Copy(FilePath, FilePath + ".bak", overwrite: true);
+ return new AppConfig();
+ }
+ }
+
+ ///
+ /// Writes the configuration directly to disk (no atomic replace).
+ ///
+ /// The configuration to serialize.
+ public void Save(AppConfig config)
+ {
+ // Create the parent folder on demand; normally it already exists.
+ string? directory = Path.GetDirectoryName(FilePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllText(FilePath, JsonConvert.SerializeObject(config, SerializerSettings));
+ }
+}
diff --git a/YKanBan/Storage/AppData/AppDataPaths.cs b/YKanBan/Storage/AppData/AppDataPaths.cs
new file mode 100644
index 0000000..40cdd1e
--- /dev/null
+++ b/YKanBan/Storage/AppData/AppDataPaths.cs
@@ -0,0 +1,20 @@
+namespace YKanBan.Storage.AppData;
+
+///
+/// Well-known locations under the per-user application data folder
+/// (%APPDATA%/YKanBan on Windows and the equivalent directory on other
+/// platforms).
+///
+public static class AppDataPaths
+{
+ ///
+ /// Gets the per-user YKanBan application data folder.
+ ///
+ public static string Directory =>
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "YKanBan");
+
+ ///
+ /// Gets the full path of app.json.
+ ///
+ public static string AppConfigJsonPath => Path.Combine(Directory, "app.json");
+}
diff --git a/YKanBan/Storage/SchemaMigration.cs b/YKanBan/Storage/SchemaMigration.cs
new file mode 100644
index 0000000..f9bc9cc
--- /dev/null
+++ b/YKanBan/Storage/SchemaMigration.cs
@@ -0,0 +1,11 @@
+namespace YKanBan.Storage;
+
+///
+/// A single incremental schema step. Migrations form a contiguous list
+/// starting at version 1; each step is applied when the stored
+/// user_version is exactly one below .
+///
+/// Target user_version after this migration is applied.
+/// Short human-readable identifier used for diagnostics.
+/// SQL script executed atomically together with the version bump.
+public sealed record SchemaMigration(int Version, string Name, string Sql);
diff --git a/YKanBan/Storage/SqliteDatabase.cs b/YKanBan/Storage/SqliteDatabase.cs
new file mode 100644
index 0000000..0b5d973
--- /dev/null
+++ b/YKanBan/Storage/SqliteDatabase.cs
@@ -0,0 +1,126 @@
+using Microsoft.Data.Sqlite;
+
+namespace YKanBan.Storage;
+
+///
+/// Shared SQLite plumbing for workspace databases: opens a connection with
+/// the mandated PRAGMA configuration (WAL journal, NORMAL synchronous mode,
+/// foreign keys on) and applies incremental user_version migrations.
+///
+public static class SqliteDatabase
+{
+ ///
+ /// Opens (creating if needed) a database file, applies the mandatory
+ /// PRAGMAs and runs any pending migrations.
+ ///
+ /// Path of the SQLite database file.
+ /// Contiguous migration list ordered by version, starting at 1.
+ /// An open connection with all PRAGMAs applied and the schema migrated.
+ public static SqliteConnection Open(string databasePath, IReadOnlyList migrations)
+ {
+ var connection = new SqliteConnection(new SqliteConnectionStringBuilder
+ {
+ DataSource = databasePath,
+ Mode = SqliteOpenMode.ReadWriteCreate,
+
+ // Each process holds its own single connection; a pool would only add noise.
+ Pooling = false,
+ }.ToString());
+
+ connection.Open();
+ try
+ {
+ ApplyPragmas(connection);
+ ApplyMigrations(connection, migrations);
+ return connection;
+ }
+ catch
+ {
+ connection.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Reads the stored PRAGMA user_version value.
+ ///
+ /// An open database connection.
+ /// The stored schema version.
+ public static long ReadUserVersion(SqliteConnection connection)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = "PRAGMA user_version;";
+ return (long)command.ExecuteScalar()!;
+ }
+
+ ///
+ /// Applies the per-connection PRAGMAs. WAL is a persistent database
+ /// property, but the statement is harmless to repeat; foreign keys must be
+ /// enabled on every connection or the DDL cascades never fire.
+ ///
+ /// An open database connection.
+ internal static void ApplyPragmas(SqliteConnection connection)
+ {
+ // WAL plus NORMAL synchronous is the chosen durability/performance trade-off.
+ Execute(connection, "PRAGMA journal_mode=WAL;");
+ Execute(connection, "PRAGMA synchronous=NORMAL;");
+ Execute(connection, "PRAGMA foreign_keys=ON;");
+ }
+
+ ///
+ /// Runs every migration newer than the stored user_version, each in
+ /// its own transaction together with its version bump.
+ ///
+ /// An open database connection.
+ /// Contiguous migration list ordered by version, starting at 1.
+ /// The migration list is not contiguous from version 1.
+ /// The database was written by a newer build.
+ internal static void ApplyMigrations(SqliteConnection connection, IReadOnlyList migrations)
+ {
+ // Contract check: the list must be ordered, contiguous and start at version 1.
+ for (int index = 0; index < migrations.Count; index++)
+ {
+ if (migrations[index].Version != index + 1)
+ {
+ throw new ArgumentException("Migrations must be contiguous and start at version 1.", nameof(migrations));
+ }
+ }
+
+ long current = ReadUserVersion(connection);
+ int latest = migrations.Count;
+
+ // A database written by a newer build cannot be managed by this one; refuse loudly.
+ if (current > latest)
+ {
+ throw new SchemaVersionException(current, latest);
+ }
+
+ // Apply each pending migration atomically so a crash never leaves a half-migrated database.
+ foreach (SchemaMigration migration in migrations)
+ {
+ if (migration.Version <= current)
+ {
+ continue;
+ }
+
+ using var transaction = connection.BeginTransaction();
+ Execute(connection, migration.Sql, transaction);
+ Execute(connection, $"PRAGMA user_version={migration.Version};", transaction);
+ transaction.Commit();
+ }
+ }
+
+ ///
+ /// Executes a non-query SQL script, optionally inside an explicit transaction.
+ ///
+ /// An open database connection.
+ /// The SQL script to execute.
+ /// The transaction to enlist in, when any.
+ private static void Execute(SqliteConnection connection, string commandText, SqliteTransaction? transaction = null)
+ {
+ using var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = commandText;
+ command.ExecuteNonQuery();
+ }
+}
diff --git a/YKanBan/Storage/StorageExceptions.cs b/YKanBan/Storage/StorageExceptions.cs
new file mode 100644
index 0000000..4e6b69d
--- /dev/null
+++ b/YKanBan/Storage/StorageExceptions.cs
@@ -0,0 +1,138 @@
+namespace YKanBan.Storage;
+
+///
+/// Base class for the typed exceptions raised by the storage layer.
+/// Exception messages are always English and never localized; the UI maps the
+/// exception type to a localized resource key instead.
+///
+public abstract class YKanBanException : Exception
+{
+ ///
+ /// Initializes the exception with an English diagnostic message.
+ ///
+ /// The English diagnostic message.
+ /// The underlying cause, when any.
+ protected YKanBanException(string message, Exception? innerException = null)
+ : base(message, innerException)
+ {
+ }
+}
+
+///
+/// Signals that the folder passed on the command line does not exist on disk.
+///
+public sealed class WorkspaceDirectoryMissingException : YKanBanException
+{
+ ///
+ /// Gets the folder path that was expected to exist.
+ ///
+ public string FolderPath { get; }
+
+ ///
+ /// Initializes the exception for a folder that is absent on disk.
+ ///
+ /// The missing folder path.
+ public WorkspaceDirectoryMissingException(string folderPath)
+ : base($"The folder does not exist: {folderPath}")
+ {
+ FolderPath = folderPath;
+ }
+}
+
+///
+/// Signals that a folder exists but has no .ykanban structure, so it is not
+/// (yet) a workspace.
+///
+public sealed class WorkspaceNotInitializedException : YKanBanException
+{
+ ///
+ /// Gets the folder path that lacks the .ykanban structure.
+ ///
+ public string FolderPath { get; }
+
+ ///
+ /// Initializes the exception for a folder without a .ykanban structure.
+ ///
+ /// The uninitialized folder path.
+ public WorkspaceNotInitializedException(string folderPath)
+ : base($"The folder is not a YKanBan workspace (missing .ykanban): {folderPath}")
+ {
+ FolderPath = folderPath;
+ }
+}
+
+///
+/// Signals that another instance already holds the workspace lock, so this
+/// process must not touch the workspace.
+///
+public sealed class WorkspaceLockException : YKanBanException
+{
+ ///
+ /// Gets the lock file that could not be acquired.
+ ///
+ public string LockFilePath { get; }
+
+ ///
+ /// Gets the best-effort holder diagnostics read from the lock file, or
+ /// when they could not be read.
+ ///
+ public string? HolderDiagnostics { get; }
+
+ ///
+ /// Initializes the exception for a failed lock acquisition.
+ ///
+ /// The lock file that is already held.
+ /// Diagnostics of the current holder, when readable.
+ /// The underlying I/O error.
+ public WorkspaceLockException(string lockFilePath, string? holderDiagnostics, Exception innerException)
+ : base(BuildMessage(lockFilePath, holderDiagnostics), innerException)
+ {
+ LockFilePath = lockFilePath;
+ HolderDiagnostics = holderDiagnostics;
+ }
+
+ ///
+ /// Composes the diagnostic message, appending holder details when they were readable.
+ ///
+ /// The lock file that is already held.
+ /// Diagnostics of the current holder, when readable.
+ /// The English diagnostic message.
+ private static string BuildMessage(string lockFilePath, string? holderDiagnostics)
+ {
+ string message = $"The workspace is locked by another instance: {lockFilePath}";
+ if (!string.IsNullOrEmpty(holderDiagnostics))
+ {
+ message += $" Lock holder: {holderDiagnostics}";
+ }
+ return message;
+ }
+}
+
+///
+/// Signals that the database was written by a newer build than the one
+/// currently running, so its schema cannot be safely managed.
+///
+public sealed class SchemaVersionException : YKanBanException
+{
+ ///
+ /// Gets the user_version value found in the database.
+ ///
+ public long StoredVersion { get; }
+
+ ///
+ /// Gets the newest schema version this build understands.
+ ///
+ public int SupportedVersion { get; }
+
+ ///
+ /// Initializes the exception for an unmanageable schema version.
+ ///
+ /// The user_version read from the database.
+ /// The newest schema version this build supports.
+ public SchemaVersionException(long storedVersion, int supportedVersion)
+ : base($"Database schema version {storedVersion} is newer than the latest supported version {supportedVersion}.")
+ {
+ StoredVersion = storedVersion;
+ SupportedVersion = supportedVersion;
+ }
+}
diff --git a/YKanBan/Storage/UnixTime.cs b/YKanBan/Storage/UnixTime.cs
new file mode 100644
index 0000000..da0e3f6
--- /dev/null
+++ b/YKanBan/Storage/UnixTime.cs
@@ -0,0 +1,13 @@
+namespace YKanBan.Storage;
+
+///
+/// Supplies the current Unix timestamp. Every timestamp persisted by YKanBan
+/// is an INTEGER count of Unix seconds.
+///
+public static class UnixTime
+{
+ ///
+ /// Gets the current UTC time expressed as Unix seconds.
+ ///
+ public static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+}
diff --git a/YKanBan/Storage/Workspace/WorkspaceInitializer.cs b/YKanBan/Storage/Workspace/WorkspaceInitializer.cs
new file mode 100644
index 0000000..25a5307
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceInitializer.cs
@@ -0,0 +1,45 @@
+using Microsoft.Data.Sqlite;
+
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Creates the .ykanban structure for a folder: the folder itself and an
+/// empty, fully migrated database. Adding preset content is deliberately a
+/// separate concern handled by , so the
+/// database can be created without any rows.
+///
+public static class WorkspaceInitializer
+{
+ ///
+ /// Returns whether the folder contains a .ykanban structure. Only the
+ /// passed folder is checked; parent folders are never searched.
+ ///
+ /// The folder to inspect.
+ /// when .ykanban exists in the folder.
+ public static bool IsWorkspace(string folderPath) => Directory.Exists(WorkspacePaths.Root(folderPath));
+
+ ///
+ /// Initializes a fresh workspace: creates .ykanban and an empty migrated
+ /// database, without adding any preset content.
+ ///
+ /// The existing folder to initialize.
+ /// The folder does not exist on disk.
+ /// The workspace database already exists.
+ public static void Initialize(string folderPath)
+ {
+ if (!Directory.Exists(folderPath))
+ {
+ throw new WorkspaceDirectoryMissingException(folderPath);
+ }
+
+ string databasePath = WorkspacePaths.Database(folderPath);
+ if (File.Exists(databasePath))
+ {
+ throw new InvalidOperationException($"The workspace database already exists: {databasePath}");
+ }
+
+ // Create the .ykanban folder, then the migrated but still empty database.
+ Directory.CreateDirectory(WorkspacePaths.Root(folderPath));
+ using SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations);
+ }
+}
diff --git a/YKanBan/Storage/Workspace/WorkspaceLock.cs b/YKanBan/Storage/Workspace/WorkspaceLock.cs
new file mode 100644
index 0000000..b421770
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceLock.cs
@@ -0,0 +1,91 @@
+using System.Text;
+
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Exclusive, OS-enforced lock over a workspace's ykanban.lock. The handle is
+/// held until disposal, so crashes and power loss release it automatically.
+/// The lock file also carries PID / machine name / time for human diagnostics.
+///
+public sealed class WorkspaceLock : IDisposable
+{
+ private readonly FileStream _stream;
+
+ ///
+ /// Initializes the lock around an already-acquired handle.
+ ///
+ /// The exclusively opened lock file stream.
+ /// Path of the lock file being held.
+ private WorkspaceLock(FileStream stream, string lockFilePath)
+ {
+ _stream = stream;
+ LockFilePath = lockFilePath;
+ }
+
+ ///
+ /// Gets the path of the lock file being held.
+ ///
+ public string LockFilePath { get; }
+
+ ///
+ /// Acquires the workspace lock, writing this process's diagnostics into it.
+ ///
+ /// The workspace folder to lock.
+ /// The held lock; dispose it to release.
+ /// The folder has no .ykanban structure.
+ /// Another instance already holds the lock.
+ public static WorkspaceLock Acquire(string folderPath)
+ {
+ string lockFilePath = WorkspacePaths.LockFile(folderPath);
+ FileStream stream;
+ try
+ {
+ // FileShare.None gives the exclusive semantics; FileMode.Create truncates stale content.
+ stream = new FileStream(lockFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
+ }
+ catch (DirectoryNotFoundException)
+ {
+ throw new WorkspaceNotInitializedException(folderPath);
+ }
+ catch (IOException ex)
+ {
+ throw new WorkspaceLockException(lockFilePath, ReadDiagnosticsBestEffort(lockFilePath), ex);
+ }
+
+ // Write holder diagnostics so humans can identify the owning instance from the file alone.
+ string diagnostics = $"pid={Environment.ProcessId};machine={Environment.MachineName};time={DateTimeOffset.UtcNow:O}";
+ byte[] payload = Encoding.UTF8.GetBytes(diagnostics);
+ stream.Write(payload, 0, payload.Length);
+ stream.Flush();
+ return new WorkspaceLock(stream, lockFilePath);
+ }
+
+ ///
+ /// Attempts to read holder diagnostics from a lock file we failed to
+ /// acquire. The read is best-effort: a live FileShare.None holder makes the
+ /// file unreadable, in which case is returned.
+ ///
+ /// The lock file to read.
+ /// The diagnostics text, or when unreadable.
+ private static string? ReadDiagnosticsBestEffort(string lockFilePath)
+ {
+ try
+ {
+ return File.ReadAllText(lockFilePath);
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Releases the exclusive handle. The lock file itself is intentionally
+ /// left on disk; the OS releases the handle even on a crash.
+ ///
+ public void Dispose() => _stream.Dispose();
+}
diff --git a/YKanBan/Storage/Workspace/WorkspacePaths.cs b/YKanBan/Storage/Workspace/WorkspacePaths.cs
new file mode 100644
index 0000000..97e364e
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspacePaths.cs
@@ -0,0 +1,44 @@
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Well-known file and folder names inside a workspace's .ykanban structure,
+/// plus helpers that combine them with a workspace folder path.
+///
+public static class WorkspacePaths
+{
+ ///
+ /// Name of the workspace root folder created inside a managed project.
+ ///
+ public const string RootFolderName = ".ykanban";
+
+ ///
+ /// Name of the SQLite database inside .ykanban.
+ ///
+ public const string DatabaseFileName = "ykanban.db";
+
+ ///
+ /// Name of the exclusive lock file inside .ykanban.
+ ///
+ public const string LockFileName = "ykanban.lock";
+
+ ///
+ /// Returns the .ykanban root path for a workspace folder.
+ ///
+ /// The workspace folder path.
+ /// The path of the .ykanban folder.
+ public static string Root(string folderPath) => Path.Combine(folderPath, RootFolderName);
+
+ ///
+ /// Returns the workspace database path for a workspace folder.
+ ///
+ /// The workspace folder path.
+ /// The path of ykanban.db.
+ public static string Database(string folderPath) => Path.Combine(Root(folderPath), DatabaseFileName);
+
+ ///
+ /// Returns the workspace lock file path for a workspace folder.
+ ///
+ /// The workspace folder path.
+ /// The path of ykanban.lock.
+ public static string LockFile(string folderPath) => Path.Combine(Root(folderPath), LockFileName);
+}
diff --git a/YKanBan/Storage/Workspace/WorkspacePreset.cs b/YKanBan/Storage/Workspace/WorkspacePreset.cs
new file mode 100644
index 0000000..db27551
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspacePreset.cs
@@ -0,0 +1,50 @@
+using Microsoft.Data.Sqlite;
+
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Adds the initial workspace content. The three preset column titles are read
+/// straight from the ResX resources in the UI language active when they are
+/// added; they then become ordinary data that no longer follows language
+/// switches.
+///
+public static class WorkspacePreset
+{
+ ///
+ /// Inserts the three preset columns (the current language's equivalents of
+ /// To Do / In Progress / Done) in a single transaction.
+ ///
+ /// The initialized workspace folder.
+ /// The workspace database does not exist or a title collides with an existing column.
+ public static void AddPresetColumns(string folderPath)
+ {
+ using SqliteConnection connection = SqliteDatabase.Open(
+ WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
+
+ long now = UnixTime.Now;
+
+ // Preset titles are data at creation time: whatever the current language says gets stored.
+ string[] titles =
+ [
+ Resources.PresetColumn_Todo,
+ Resources.PresetColumn_InProgress,
+ Resources.PresetColumn_Done,
+ ];
+
+ // Insert all three atomically so a partially populated workspace can never exist.
+ using var transaction = connection.BeginTransaction();
+ foreach (string title in titles)
+ {
+ using var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = """
+ INSERT INTO columns (title, description, created_at, updated_at)
+ VALUES ($title, '', $now, $now);
+ """;
+ command.Parameters.AddWithValue("$title", title);
+ command.Parameters.AddWithValue("$now", now);
+ command.ExecuteNonQuery();
+ }
+ transaction.Commit();
+ }
+}
diff --git a/YKanBan/Storage/Workspace/WorkspaceSchema.cs b/YKanBan/Storage/Workspace/WorkspaceSchema.cs
new file mode 100644
index 0000000..4e91e22
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceSchema.cs
@@ -0,0 +1,54 @@
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Schema definitions for the per-workspace database. A workspace holds a
+/// single board with a columns → cards hierarchy plus tags and their
+/// assignments. Data validity is expressed in DDL (CHECK / UNIQUE / FOREIGN
+/// KEY) so every stored row is valid by construction, and cascading deletes
+/// are covered by the same DDL.
+///
+public static class WorkspaceSchema
+{
+ ///
+ /// Newest workspace schema version this build understands.
+ ///
+ public const int CurrentVersion = 1;
+
+ ///
+ /// Ordered, contiguous migration list for the workspace database.
+ ///
+ public static readonly IReadOnlyList Migrations =
+ [
+ new SchemaMigration(CurrentVersion, "initial schema", """
+ CREATE TABLE columns (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL UNIQUE CHECK (length(title) > 0),
+ description TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE cards (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ column_id INTEGER NOT NULL REFERENCES columns (id) ON DELETE CASCADE,
+ title TEXT NOT NULL DEFAULT '',
+ content TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE tags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE CHECK (length(name) > 0),
+ color TEXT NOT NULL CHECK (color GLOB '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'),
+ description TEXT NOT NULL DEFAULT ''
+ );
+
+ CREATE TABLE card_tags (
+ card_id INTEGER NOT NULL REFERENCES cards (id) ON DELETE CASCADE,
+ tag_id INTEGER NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
+ PRIMARY KEY (card_id, tag_id)
+ );
+ """),
+ ];
+}
diff --git a/YKanBan/YKanBan.csproj b/YKanBan/YKanBan.csproj
index 1ba36d1..72cd205 100644
--- a/YKanBan/YKanBan.csproj
+++ b/YKanBan/YKanBan.csproj
@@ -2,6 +2,7 @@
WinExe
net9.0
+ enable
enable
true
app.manifest
@@ -9,7 +10,29 @@
-
+
+
+
+
+
+
+
+ MSBuild:Compile
+ $(IntermediateOutputPath)Resources.Designer.cs
+ CSharp
+ YKanBan
+ Resources
+ true
+ YKanBan.Resources.resources
+
+
+ YKanBan.Resources.zh-Hans.resources
+