diff --git a/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
new file mode 100644
index 0000000..8f13caf
--- /dev/null
+++ b/YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs
@@ -0,0 +1,169 @@
+using YKanBan.Storage.AppData;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.AppData;
+
+/// Tests for app.json load/save and its degradation rules.
+[TestClass]
+public class AppConfigStoreTests
+{
+ [TestMethod]
+ public void MissingFileYieldsDefaults()
+ {
+ 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("en", config.Language);
+ Assert.AreEqual(ThemeOption.FollowSystem, config.Theme);
+ Assert.AreEqual(CardSortOption.Id, config.Sort.Card);
+ Assert.AreEqual(SidebarSortOption.NameAsc, config.Sort.Sidebar);
+ 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 CorruptJsonIsBackedUpAndDefaultsAreUsed()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ File.WriteAllText(path, "{{{ not json");
+ var store = new AppConfigStore(path);
+
+ AppConfig config = store.Load();
+
+ Assert.AreEqual("en", config.Language);
+ Assert.IsTrue(File.Exists(path + ".bak"));
+ Assert.AreEqual("{{{ not json", File.ReadAllText(path + ".bak"));
+ }
+
+ [TestMethod]
+ public void WrongShapeJsonIsBackedUpAndDefaultsAreUsed()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ File.WriteAllText(path, "[1, 2, 3]");
+ var store = new AppConfigStore(path);
+
+ AppConfig config = store.Load();
+
+ Assert.AreEqual(AppConfig.CurrentFormatVersion, config.Version);
+ Assert.IsTrue(File.Exists(path + ".bak"));
+ }
+
+ [TestMethod]
+ public void SaveAndLoadRoundTripsAllSettings()
+ {
+ using var directory = new TempDirectory();
+ var store = new AppConfigStore(Path.Combine(directory.FullPath, "app.json"));
+
+ store.Save(new AppConfig
+ {
+ Language = "zh-Hans",
+ Theme = ThemeOption.Dark,
+ Sort = new SortSettings
+ {
+ Card = CardSortOption.Title,
+ Sidebar = SidebarSortOption.RecentlyUpdated,
+ },
+ ColumnWidth = new ColumnWidthSettings
+ {
+ Preset = ColumnWidthPreset.Custom,
+ CustomPixels = 500,
+ },
+ Confirmations = new ConfirmationSettings
+ {
+ DeleteCard = false,
+ DeleteColumn = false,
+ DeleteTag = false,
+ DiscardEdit = false,
+ },
+ });
+ AppConfig loaded = store.Load();
+
+ Assert.AreEqual("zh-Hans", loaded.Language);
+ Assert.AreEqual(ThemeOption.Dark, loaded.Theme);
+ Assert.AreEqual(CardSortOption.Title, loaded.Sort.Card);
+ Assert.AreEqual(SidebarSortOption.RecentlyUpdated, loaded.Sort.Sidebar);
+ Assert.AreEqual(ColumnWidthPreset.Custom, loaded.ColumnWidth.Preset);
+ Assert.AreEqual(500, loaded.ColumnWidth.CustomPixels);
+ Assert.IsFalse(loaded.Confirmations.DeleteCard);
+ Assert.IsFalse(loaded.Confirmations.DeleteColumn);
+ Assert.IsFalse(loaded.Confirmations.DeleteTag);
+ Assert.IsFalse(loaded.Confirmations.DiscardEdit);
+ }
+
+ [TestMethod]
+ public void SaveWritesKebabCaseNamesAndVersionField()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ var store = new AppConfigStore(path);
+
+ store.Save(new AppConfig());
+ string json = File.ReadAllText(path);
+
+ // Keys and enum values are serialized in kebab-case; "version" is the migration hook.
+ Assert.IsTrue(json.Contains($"\"version\": {AppConfig.CurrentFormatVersion}"));
+ Assert.IsTrue(json.Contains("\"language\": \"en\""));
+ Assert.IsTrue(json.Contains("\"theme\": \"follow-system\""));
+ Assert.IsTrue(json.Contains("\"sort\":"));
+ Assert.IsTrue(json.Contains("\"card\": \"id\""));
+ Assert.IsTrue(json.Contains("\"sidebar\": \"name-asc\""));
+ Assert.IsTrue(json.Contains("\"column-width\":"));
+ Assert.IsTrue(json.Contains("\"preset\": \"standard\""));
+ Assert.IsTrue(json.Contains("\"custom-pixels\": 320"));
+ Assert.IsTrue(json.Contains("\"confirmations\":"));
+ Assert.IsTrue(json.Contains("\"delete-card\": true"));
+ Assert.IsTrue(json.Contains("\"delete-column\": true"));
+ Assert.IsTrue(json.Contains("\"delete-tag\": true"));
+ Assert.IsTrue(json.Contains("\"discard-edit\": true"));
+ }
+
+ [TestMethod]
+ public void MissingSectionsFallBackToTheirDefaults()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ File.WriteAllText(path, $$"""
+ {
+ "version": {{AppConfig.CurrentFormatVersion}},
+ "language": "zh-Hans"
+ }
+ """);
+ var store = new AppConfigStore(path);
+
+ AppConfig config = store.Load();
+
+ // The present field loads; absent sections each fall back to defaults.
+ Assert.AreEqual("zh-Hans", config.Language);
+ Assert.AreEqual(CardSortOption.Id, config.Sort.Card);
+ Assert.AreEqual(ColumnWidthPreset.Standard, config.ColumnWidth.Preset);
+ Assert.IsTrue(config.Confirmations.DeleteCard);
+ }
+
+ [TestMethod]
+ public void LoadPreservesUnknownFieldsTolerantly()
+ {
+ using var directory = new TempDirectory();
+ string path = Path.Combine(directory.FullPath, "app.json");
+ // A newer version writes an unknown field plus a known one.
+ File.WriteAllText(path, $$"""
+ {
+ "version": {{AppConfig.CurrentFormatVersion}},
+ "language": "zh-Hans",
+ "some-future-field": 42
+ }
+ """);
+ var store = new AppConfigStore(path);
+
+ AppConfig config = store.Load();
+
+ Assert.AreEqual("zh-Hans", config.Language);
+ }
+}
diff --git a/YKanBan.Tests/Storage/AppData/AppDatabaseTests.cs b/YKanBan.Tests/Storage/AppData/AppDatabaseTests.cs
new file mode 100644
index 0000000..8fd5a45
--- /dev/null
+++ b/YKanBan.Tests/Storage/AppData/AppDatabaseTests.cs
@@ -0,0 +1,136 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Storage;
+using YKanBan.Storage.AppData;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.AppData;
+
+/// Tests for the global app.db workspace registry.
+[TestClass]
+public class AppDatabaseTests
+{
+ private static string DatabasePath(TempDirectory directory)
+ => Path.Combine(directory.FullPath, "app.db");
+
+ [TestMethod]
+ public void UpsertInsertsRowWithTimestamps()
+ {
+ using var directory = new TempDirectory();
+ long now = 1000;
+ using var db = new AppDatabase(DatabasePath(directory), () => now);
+
+ db.UpsertWorkspace(@"C:\work\repo");
+
+ IReadOnlyList records = db.GetWorkspaces();
+ Assert.AreEqual(1, records.Count);
+ Assert.AreEqual(@"C:\work\repo", records[0].Path);
+ Assert.AreEqual(1000L, records[0].AddedAt);
+ Assert.AreEqual(1000L, records[0].LastEditedAt);
+ }
+
+ [TestMethod]
+ public void RepeatedUpsertKeepsOriginalAddedAt()
+ {
+ using var directory = new TempDirectory();
+ long now = 1000;
+ using var db = new AppDatabase(DatabasePath(directory), () => now);
+
+ db.UpsertWorkspace(@"C:\work\repo");
+ now = 2000;
+ db.UpsertWorkspace(@"C:\work\repo");
+
+ WorkspaceRecord record = db.GetWorkspaces().Single();
+ Assert.AreEqual(1000L, record.AddedAt);
+ Assert.AreEqual(2000L, record.LastEditedAt);
+ }
+
+ [TestMethod]
+ public void TouchRefreshesLastEditedAt()
+ {
+ using var directory = new TempDirectory();
+ long now = 1000;
+ using var db = new AppDatabase(DatabasePath(directory), () => now);
+
+ db.UpsertWorkspace(@"C:\work\repo");
+ now = 2500;
+ db.TouchLastEdited(@"C:\work\repo");
+
+ WorkspaceRecord record = db.GetWorkspaces().Single();
+ Assert.AreEqual(1000L, record.AddedAt);
+ Assert.AreEqual(2500L, record.LastEditedAt);
+ }
+
+ [TestMethod]
+ public void TouchOnUnknownPathRegistersTheWorkspace()
+ {
+ using var directory = new TempDirectory();
+ long now = 3000;
+ using var db = new AppDatabase(DatabasePath(directory), () => now);
+
+ // Self-healing: touching an unregistered workspace still leaves a valid row.
+ db.TouchLastEdited(@"C:\work\other");
+
+ WorkspaceRecord record = db.GetWorkspaces().Single();
+ Assert.AreEqual(3000L, record.AddedAt);
+ Assert.AreEqual(3000L, record.LastEditedAt);
+ }
+
+ [TestMethod]
+ public void RemoveDeletesTheRow()
+ {
+ using var directory = new TempDirectory();
+ using var db = new AppDatabase(DatabasePath(directory));
+
+ db.UpsertWorkspace(@"C:\work\repo");
+ db.RemoveWorkspace(@"C:\work\repo");
+
+ Assert.AreEqual(0, db.GetWorkspaces().Count);
+ }
+
+ [TestMethod]
+ public void RowsPersistAcrossReopen()
+ {
+ using var directory = new TempDirectory();
+ string path = DatabasePath(directory);
+
+ using (var db = new AppDatabase(path, () => 1000))
+ {
+ db.UpsertWorkspace(@"C:\work\repo");
+ }
+
+ using var reopened = new AppDatabase(path, () => 9999);
+ WorkspaceRecord record = reopened.GetWorkspaces().Single();
+ Assert.AreEqual(@"C:\work\repo", record.Path);
+ Assert.AreEqual(1000L, record.AddedAt);
+ }
+
+ [TestMethod]
+ public void OpenMigratesToCurrentSchemaVersion()
+ {
+ using var directory = new TempDirectory();
+ string path = DatabasePath(directory);
+ using (var db = new AppDatabase(path))
+ {
+ }
+
+ using var raw = new SqliteConnection($"Data Source={path}");
+ raw.Open();
+ Assert.AreEqual(AppDatabase.CurrentSchemaVersion, SqliteDatabase.ReadUserVersion(raw));
+ }
+
+ [TestMethod]
+ public void OpenAppliesWalJournalMode()
+ {
+ using var directory = new TempDirectory();
+ string path = DatabasePath(directory);
+ using (var db = new AppDatabase(path))
+ {
+ }
+
+ // WAL is a persistent property of the database file: a plain new
+ // connection reports it without any PRAGMA replay.
+ using var raw = new SqliteConnection($"Data Source={path}");
+ raw.Open();
+ Assert.AreEqual("wal", SqliteTestHelper.ScalarString(raw, "PRAGMA journal_mode;"));
+ }
+}
diff --git a/YKanBan.Tests/Storage/SqliteDatabaseTests.cs b/YKanBan.Tests/Storage/SqliteDatabaseTests.cs
new file mode 100644
index 0000000..7622821
--- /dev/null
+++ b/YKanBan.Tests/Storage/SqliteDatabaseTests.cs
@@ -0,0 +1,114 @@
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage;
+
+/// Tests for PRAGMA application and user_version migration machinery.
+[TestClass]
+public class SqliteDatabaseTests
+{
+ private static readonly SchemaMigration[] V1Only =
+ [
+ new SchemaMigration(1, "v1", "CREATE TABLE t1 (id INTEGER PRIMARY KEY);"),
+ ];
+
+ private static readonly SchemaMigration[] V1AndV2 =
+ [
+ new SchemaMigration(1, "v1", "CREATE TABLE t1 (id INTEGER PRIMARY KEY);"),
+ new SchemaMigration(2, "v2", "CREATE TABLE t2 (id INTEGER PRIMARY KEY);"),
+ ];
+
+ [TestMethod]
+ public void OpenAppliesMandatedPragmas()
+ {
+ using var directory = new TempDirectory();
+ string dbPath = Path.Combine(directory.FullPath, "test.db");
+
+ using var connection = SqliteDatabase.Open(dbPath, []);
+ 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 OpenSetsUserVersionToLatestWorkspaceMigration()
+ {
+ using var directory = new TempDirectory();
+ string dbPath = Path.Combine(directory.FullPath, "test.db");
+
+ using var connection = SqliteDatabase.Open(dbPath, WorkspaceSchema.Migrations);
+ Assert.AreEqual(WorkspaceSchema.CurrentVersion, SqliteDatabase.ReadUserVersion(connection));
+ }
+
+ [TestMethod]
+ public void MigrationsApplyIncrementallyOnReopen()
+ {
+ using var directory = new TempDirectory();
+ string dbPath = Path.Combine(directory.FullPath, "test.db");
+
+ // First open with only v1 available: v1 applied, v2 table absent.
+ using (var connection = SqliteDatabase.Open(dbPath, V1Only))
+ {
+ Assert.AreEqual(1L, SqliteDatabase.ReadUserVersion(connection));
+ Assert.IsTrue(SqliteTestHelper.TableExists(connection, "t1"));
+ Assert.IsFalse(SqliteTestHelper.TableExists(connection, "t2"));
+ }
+
+ // Reopen with v1+v2: only v2 runs, v1 data untouched.
+ using (var connection = SqliteDatabase.Open(dbPath, V1AndV2))
+ {
+ Assert.AreEqual(2L, SqliteDatabase.ReadUserVersion(connection));
+ Assert.IsTrue(SqliteTestHelper.TableExists(connection, "t1"));
+ Assert.IsTrue(SqliteTestHelper.TableExists(connection, "t2"));
+ }
+ }
+
+ [TestMethod]
+ public void ReopenWithCurrentSchemaIsIdempotent()
+ {
+ using var directory = new TempDirectory();
+ string dbPath = Path.Combine(directory.FullPath, "test.db");
+
+ SqliteDatabase.Open(dbPath, V1AndV2).Dispose();
+ using (var connection = SqliteDatabase.Open(dbPath, V1AndV2))
+ {
+ // Still at version 2; re-running is a no-op.
+ Assert.AreEqual(2L, SqliteDatabase.ReadUserVersion(connection));
+ SqliteTestHelper.Exec(connection, "INSERT INTO t1 (id) VALUES (42);");
+ }
+
+ using (var connection = SqliteDatabase.Open(dbPath, V1AndV2))
+ {
+ Assert.AreEqual(42L, SqliteTestHelper.ScalarLong(connection, "SELECT id FROM t1;"));
+ }
+ }
+
+ [TestMethod]
+ public void NewerSchemaVersionIsRejected()
+ {
+ using var directory = new TempDirectory();
+ string dbPath = Path.Combine(directory.FullPath, "test.db");
+
+ // Simulate a database written by a newer build.
+ using (var connection = SqliteDatabase.Open(dbPath, []))
+ {
+ SqliteTestHelper.Exec(connection, "PRAGMA user_version=5;");
+ }
+
+ SchemaVersionException ex = Assert.ThrowsExactly(
+ () => SqliteDatabase.Open(dbPath, V1AndV2));
+ Assert.AreEqual(5L, ex.StoredVersion);
+ Assert.AreEqual(2, ex.SupportedVersion);
+ }
+
+ [TestMethod]
+ public void NonContiguousMigrationListIsRejected()
+ {
+ using var directory = new TempDirectory();
+ string dbPath = Path.Combine(directory.FullPath, "test.db");
+ var bad = new[] { new SchemaMigration(2, "gap", "CREATE TABLE t (id INTEGER);") };
+
+ Assert.ThrowsExactly(() => SqliteDatabase.Open(dbPath, bad));
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/PresetColumnCultureTests.cs b/YKanBan.Tests/Storage/Workspace/PresetColumnCultureTests.cs
new file mode 100644
index 0000000..25c3b68
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/PresetColumnCultureTests.cs
@@ -0,0 +1,59 @@
+using System.Globalization;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+///
+/// Verifies that preset column titles follow the UI language active at
+/// seeding time and then live as ordinary data. Mutates the global
+/// Resources.Culture, therefore marked DoNotParallelize.
+///
+[TestClass]
+[DoNotParallelize]
+public class PresetColumnCultureTests
+{
+ [TestMethod]
+ public void PresetTitlesAreEnglishUnderEnglishCulture()
+ {
+ CultureInfo? original = Resources.Culture;
+ try
+ {
+ Resources.Culture = new CultureInfo("en");
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+ WorkspacePreset.SeedColumns(directory.FullPath);
+
+ using var connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
+ CollectionAssert.AreEqual(
+ new[] { "To Do", "In Progress", "Done" },
+ SqliteTestHelper.GetColumnTitles(connection));
+ }
+ finally
+ {
+ Resources.Culture = original;
+ }
+ }
+
+ [TestMethod]
+ public void PresetTitlesAreChineseUnderChineseCulture()
+ {
+ CultureInfo? original = Resources.Culture;
+ try
+ {
+ Resources.Culture = new CultureInfo("zh-Hans");
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+ WorkspacePreset.SeedColumns(directory.FullPath);
+
+ using var connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
+ CollectionAssert.AreEqual(
+ new[] { "待办", "进行中", "已完成" },
+ SqliteTestHelper.GetColumnTitles(connection));
+ }
+ finally
+ {
+ Resources.Culture = original;
+ }
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs
new file mode 100644
index 0000000..016b8bf
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs
@@ -0,0 +1,53 @@
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+/// Tests for .ykanban folder and empty database creation.
+[TestClass]
+public class WorkspaceInitializerTests
+{
+ [TestMethod]
+ public void InitializeCreatesYkanbanFolderAndDatabase()
+ {
+ using var directory = new TempDirectory();
+
+ Assert.IsFalse(WorkspaceInitializer.IsWorkspace(directory.FullPath));
+ WorkspaceInitializer.Initialize(directory.FullPath);
+
+ Assert.IsTrue(WorkspaceInitializer.IsWorkspace(directory.FullPath));
+ Assert.IsTrue(File.Exists(WorkspacePaths.Database(directory.FullPath)));
+ // The lock file only appears once a WorkspaceLock is acquired.
+ Assert.IsFalse(File.Exists(WorkspacePaths.LockFile(directory.FullPath)));
+ }
+
+ [TestMethod]
+ public void InitializeLeavesDatabaseEmpty()
+ {
+ // Initialization creates structure only; preset content is seeded separately.
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+
+ using var connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM columns;"));
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM cards;"));
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM tags;"));
+ }
+
+ [TestMethod]
+ public void InitializeThrowsWhenDirectoryIsMissing()
+ {
+ string missing = Path.Combine(Path.GetTempPath(), "ykanban-tests-" + Guid.NewGuid().ToString("N"));
+ Assert.ThrowsExactly(() => WorkspaceInitializer.Initialize(missing));
+ }
+
+ [TestMethod]
+ public void InitializeThrowsWhenDatabaseAlreadyExists()
+ {
+ 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..e701b12
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs
@@ -0,0 +1,57 @@
+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 ex = Assert.ThrowsExactly(
+ () => WorkspaceLock.Acquire(directory.FullPath));
+ Assert.AreEqual(WorkspacePaths.LockFile(directory.FullPath), ex.LockFilePath);
+ }
+
+ [TestMethod]
+ public void AcquireWithoutYkanbanThrowsNotInitialized()
+ {
+ using var directory = new TempDirectory();
+ Assert.ThrowsExactly(
+ () => WorkspaceLock.Acquire(directory.FullPath));
+ }
+
+ [TestMethod]
+ public void LockFileContainsHolderDiagnostics()
+ {
+ 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..729627c
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs
@@ -0,0 +1,39 @@
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+/// Tests for seeding preset workspace content.
+[TestClass]
+public class WorkspacePresetTests
+{
+ [TestMethod]
+ public void SeedAddsThreeDistinctPresetColumns()
+ {
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+ WorkspacePreset.SeedColumns(directory.FullPath);
+
+ using var connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
+ List titles = SqliteTestHelper.GetColumnTitles(connection);
+
+ Assert.AreEqual(3, titles.Count);
+ Assert.AreEqual(titles.Count, titles.Distinct().Count());
+ Assert.IsTrue(titles.All(title => title.Length > 0));
+ }
+
+ [TestMethod]
+ public void SeedPresetColumnsShareOneTimestamp()
+ {
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+ WorkspacePreset.SeedColumns(directory.FullPath);
+
+ using var connection = SqliteTestHelper.OpenWorkspace(directory.FullPath);
+
+ // Creation writes created_at = updated_at; all three columns share one moment.
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM columns WHERE created_at <> updated_at;"));
+ Assert.AreEqual(3L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM columns WHERE created_at > 0;"));
+ Assert.AreEqual(1L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(DISTINCT created_at) FROM columns;"));
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs
new file mode 100644
index 0000000..da816d4
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs
@@ -0,0 +1,160 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+/// Verifies that the DDL constraints reject invalid data at the door.
+[TestClass]
+public class WorkspaceSchemaTests
+{
+ /// Owns a temp directory plus a fresh workspace-schema database for one 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);
+ }
+
+ public void Dispose()
+ {
+ Connection.Dispose();
+ Directory.Dispose();
+ }
+ }
+
+ [TestMethod]
+ public void EmptyColumnTitleIsRejected()
+ {
+ using var db = new FreshDatabase();
+ using var connection = db.Connection;
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('', 1, 1);"));
+ }
+
+ [TestMethod]
+ public void DuplicateColumnTitleIsRejected()
+ {
+ using var db = new FreshDatabase();
+ using var connection = db.Connection;
+ SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);");
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);"));
+ }
+
+ [TestMethod]
+ public void ColumnTitlesCompareCaseSensitively()
+ {
+ using var db = new FreshDatabase();
+ using var connection = db.Connection;
+ // BINARY collation: 'Foo' and 'foo' are distinct titles.
+ SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('Foo', 1, 1);");
+ SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, created_at, updated_at) VALUES ('foo', 1, 1);");
+ Assert.AreEqual(2L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM columns;"));
+ }
+
+ [TestMethod]
+ public void EmptyTagNameIsRejected()
+ {
+ using var db = new FreshDatabase();
+ using var connection = db.Connection;
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('', '#112233');"));
+ }
+
+ [TestMethod]
+ public void TagNamesAcceptSpacesEmojiAndReservedWords()
+ {
+ using var db = new FreshDatabase();
+ using var connection = db.Connection;
+ // 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(connection, "INSERT INTO tags (name, color) VALUES ($n, '#112233');", ("$n", name));
+ }
+ Assert.AreEqual(5L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM tags;"));
+ }
+
+ [TestMethod]
+ public void DuplicateTagNameIsRejected()
+ {
+ using var db = new FreshDatabase();
+ using var connection = db.Connection;
+ SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('bug', '#112233');");
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(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();
+ using var connection = db.Connection;
+ int affected = SqliteTestHelper.Exec(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();
+ using var connection = db.Connection;
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color) VALUES ('x', $c);", ("$c", color)));
+ }
+
+ [TestMethod]
+ public void CardRequiresExistingColumn()
+ {
+ using var db = new FreshDatabase();
+ using var connection = db.Connection;
+ // foreign_keys=ON makes the FK enforceable; this is the DDL contract behind the cascades.
+ Assert.ThrowsExactly(() =>
+ SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, created_at, updated_at) VALUES (999, 1, 1);"));
+ }
+
+ [TestMethod]
+ public void CardDefaultsToEmptyTitleAndContent()
+ {
+ using var db = new FreshDatabase();
+ using var connection = db.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);");
+
+ // No title is represented by the empty string.
+ Assert.AreEqual(string.Empty, SqliteTestHelper.ScalarString(connection, "SELECT title FROM cards WHERE id = 1;"));
+ Assert.AreEqual(string.Empty, SqliteTestHelper.ScalarString(connection, "SELECT content FROM cards WHERE id = 1;"));
+ }
+
+ [TestMethod]
+ public void CardTagDuplicateAssignmentIsRejected()
+ {
+ using var db = new FreshDatabase();
+ using var connection = db.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);");
+
+ // Composite primary key: the same (card, tag) pair cannot be inserted twice.
+ Assert.ThrowsExactly(() =>
+ 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..e7a9e77
--- /dev/null
+++ b/YKanBan.Tests/TestUtilities/SqliteTestHelper.cs
@@ -0,0 +1,67 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+
+namespace YKanBan.Tests.TestUtilities;
+
+/// Shared helpers for storage tests.
+public static class SqliteTestHelper
+{
+ /// Executes a non-query SQL statement and returns the affected row count.
+ 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.
+ 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.
+ public static long ScalarLong(SqliteConnection connection, string sql)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ return (long)command.ExecuteScalar()!;
+ }
+
+ /// Returns true when a table with the given name 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.
+ 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 migration list.
+ 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..0ee4975
--- /dev/null
+++ b/YKanBan.Tests/TestUtilities/TempDirectory.cs
@@ -0,0 +1,31 @@
+namespace YKanBan.Tests.TestUtilities;
+
+///
+/// Creates a unique temporary directory per test instance and removes it on
+/// disposal. Retries the deletion once because SQLite WAL files can linger
+/// for a moment after connections close.
+///
+public sealed class TempDirectory : IDisposable
+{
+ /// 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);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(FullPath, recursive: true);
+ }
+ catch (IOException)
+ {
+ Thread.Sleep(100);
+ try { Directory.Delete(FullPath, recursive: true); } catch (IOException) { }
+ }
+ }
+}
diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx
new file mode 100644
index 0000000..323a898
--- /dev/null
+++ b/YKanBan/Assets/Locales/Resources.resx
@@ -0,0 +1,24 @@
+
+
+
+ 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..3b88be7
--- /dev/null
+++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx
@@ -0,0 +1,24 @@
+
+
+
+ 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/Storage/AppData/AppConfig.cs b/YKanBan/Storage/AppData/AppConfig.cs
new file mode 100644
index 0000000..bd9959b
--- /dev/null
+++ b/YKanBan/Storage/AppData/AppConfig.cs
@@ -0,0 +1,156 @@
+using System.Runtime.Serialization;
+using Newtonsoft.Json;
+
+namespace YKanBan.Storage.AppData;
+
+/// Card ordering options. All orders fall back to id ascending.
+public enum CardSortOption
+{
+ /// Ascending primary key (default).
+ [EnumMember(Value = "id")]
+ Id,
+
+ /// Creation time, oldest first.
+ [EnumMember(Value = "created-at")]
+ CreatedAt,
+
+ /// Last modification time, most recent first.
+ [EnumMember(Value = "updated-at")]
+ UpdatedAt,
+
+ /// Title by code points (BINARY collation); empty titles sort first.
+ [EnumMember(Value = "title")]
+ Title,
+}
+
+/// Sidebar workspace list ordering.
+public enum SidebarSortOption
+{
+ /// Folder name A to Z.
+ [EnumMember(Value = "name-asc")]
+ NameAsc,
+
+ /// Folder name Z to A.
+ [EnumMember(Value = "name-desc")]
+ NameDesc,
+
+ /// By workspace last edit time, most recent first.
+ [EnumMember(Value = "recently-updated")]
+ RecentlyUpdated,
+}
+
+/// Column width presets. Custom 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 and sidebar list ordering settings.
+public sealed class SortSettings
+{
+ /// Card order used by all workspaces (global, not per workspace).
+ [JsonProperty("card")]
+ public CardSortOption Card { get; set; } = CardSortOption.Id;
+
+ /// Sidebar workspace list order.
+ [JsonProperty("sidebar")]
+ public SidebarSortOption Sidebar { get; set; } = SidebarSortOption.NameAsc;
+}
+
+/// Board column width settings.
+public sealed class ColumnWidthSettings
+{
+ /// Width preset applied globally.
+ [JsonProperty("preset")]
+ public ColumnWidthPreset Preset { get; set; } = ColumnWidthPreset.Standard;
+
+ /// Manual width in pixels used when is Custom (range 160–720).
+ [JsonProperty("custom-pixels")]
+ public int CustomPixels { get; set; } = 320;
+}
+
+/// Confirmation-dialog toggles for destructive actions.
+public sealed class ConfirmationSettings
+{
+ /// Whether deleting a card asks for confirmation.
+ [JsonProperty("delete-card")]
+ public bool DeleteCard { get; set; } = true;
+
+ /// Whether deleting a column asks for confirmation.
+ [JsonProperty("delete-column")]
+ public bool DeleteColumn { get; set; } = true;
+
+ /// Whether deleting a tag asks for confirmation.
+ [JsonProperty("delete-tag")]
+ public bool DeleteTag { get; set; } = true;
+
+ /// Whether discarding card edits asks for confirmation.
+ [JsonProperty("discard-edit")]
+ public bool DiscardEdit { get; set; } = true;
+}
+
+///
+/// Root object of app.json — the trivial KV configuration store. Serialized
+/// as a whole with kebab-case names (declared via [JsonProperty] and
+/// [EnumMember]); the "version" field is the migration hook for future
+/// format changes.
+///
+public sealed class AppConfig
+{
+ /// Current configuration format version.
+ public const int CurrentFormatVersion = 1;
+
+ /// Configuration format version of this file.
+ [JsonProperty("version")]
+ public int Version { get; set; } = CurrentFormatVersion;
+
+ /// UI language code: "en" (default) or "zh-Hans".
+ [JsonProperty("language")]
+ public string Language { get; set; } = "en";
+
+ /// UI theme selection (default: follow the system).
+ [JsonProperty("theme")]
+ public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem;
+
+ /// Ordering settings.
+ [JsonProperty("sort")]
+ public SortSettings Sort { get; set; } = new();
+
+ /// Column width settings.
+ [JsonProperty("column-width")]
+ public ColumnWidthSettings ColumnWidth { get; set; } = new();
+
+ /// 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..844a330
--- /dev/null
+++ b/YKanBan/Storage/AppData/AppConfigStore.cs
@@ -0,0 +1,74 @@
+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() },
+ };
+
+ /// Creates a store bound to a specific app.json path.
+ public AppConfigStore(string filePath) => FilePath = filePath;
+
+ /// Full path of the managed app.json file.
+ public string FilePath { get; }
+
+ /// Loads the configuration: missing file → defaults; corrupt file → backup then defaults.
+ /// 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).
+ public void Save(AppConfig config)
+ {
+ // Create the parent folder on demand; normally it exists already.
+ 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/AppDatabase.cs b/YKanBan/Storage/AppData/AppDatabase.cs
new file mode 100644
index 0000000..3b34ddd
--- /dev/null
+++ b/YKanBan/Storage/AppData/AppDatabase.cs
@@ -0,0 +1,122 @@
+using Microsoft.Data.Sqlite;
+
+namespace YKanBan.Storage.AppData;
+
+/// A known workspace row of the global app.db.
+/// Workspace folder path as registered.
+/// Unix seconds of the last successful data write to the workspace database.
+/// Unix seconds when the workspace was registered.
+public sealed record WorkspaceRecord(string Path, long LastEditedAt, long AddedAt);
+
+///
+/// Global application database (app.db): stores the persistent workspace
+/// list. Any successful data write into a workspace database must be
+/// mirrored here by refreshing last_edited_at (the write-through is wired
+/// by the repository layer). Uses the same PRAGMA configuration as the
+/// workspace databases.
+///
+public sealed class AppDatabase : IDisposable
+{
+ /// Latest app.db schema version this build understands.
+ public const int CurrentSchemaVersion = 1;
+
+ private static readonly IReadOnlyList Migrations = new SchemaMigration[]
+ {
+ new SchemaMigration(CurrentSchemaVersion, "initial schema", """
+ CREATE TABLE workspaces (
+ path TEXT PRIMARY KEY,
+ last_edited_at INTEGER NOT NULL,
+ added_at INTEGER NOT NULL
+ );
+ """),
+ };
+
+ private readonly SqliteConnection _connection;
+ private readonly Func _unixNow;
+ private readonly object _gate = new();
+
+ /// Opens (creating if needed) the app.db file and applies pending migrations.
+ /// Path of the app.db file.
+ /// Clock override for tests; defaults to real Unix time.
+ public AppDatabase(string databasePath, Func? unixNow = null)
+ {
+ // Ensure the application data folder exists before creating the database file.
+ string? directory = Path.GetDirectoryName(databasePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ _unixNow = unixNow ?? (() => UnixTime.Now);
+ _connection = SqliteDatabase.Open(databasePath, Migrations);
+ }
+
+ /// Returns all known workspaces ordered by added_at ascending.
+ public IReadOnlyList GetWorkspaces()
+ {
+ lock (_gate)
+ {
+ using var command = _connection.CreateCommand();
+ command.CommandText = "SELECT path, last_edited_at, added_at FROM workspaces ORDER BY added_at ASC, path ASC;";
+ using var reader = command.ExecuteReader();
+
+ var records = new List();
+ while (reader.Read())
+ {
+ records.Add(new WorkspaceRecord(reader.GetString(0), reader.GetInt64(1), reader.GetInt64(2)));
+ }
+ return records;
+ }
+ }
+
+ /// Registers a workspace, or refreshes last_edited_at if it is already known.
+ public void UpsertWorkspace(string path)
+ {
+ lock (_gate)
+ {
+ long now = _unixNow();
+ using var command = _connection.CreateCommand();
+ // On conflict only last_edited_at moves; the original added_at is preserved.
+ command.CommandText = """
+ INSERT INTO workspaces (path, last_edited_at, added_at)
+ VALUES ($path, $now, $now)
+ ON CONFLICT(path) DO UPDATE SET last_edited_at = $now;
+ """;
+ command.Parameters.AddWithValue("$path", path);
+ command.Parameters.AddWithValue("$now", now);
+ command.ExecuteNonQuery();
+ }
+ }
+
+ /// Refreshes last_edited_at for a known workspace; registers it if the row is absent.
+ public void TouchLastEdited(string path)
+ {
+ lock (_gate)
+ {
+ using var command = _connection.CreateCommand();
+ command.CommandText = "UPDATE workspaces SET last_edited_at = $now WHERE path = $path;";
+ command.Parameters.AddWithValue("$path", path);
+ command.Parameters.AddWithValue("$now", _unixNow());
+ if (command.ExecuteNonQuery() == 0)
+ {
+ // Self-healing path: a workspace opened before registration still gets a row.
+ UpsertWorkspace(path);
+ }
+ }
+ }
+
+ /// Forgets a workspace entry; the .ykanban folder itself is untouched.
+ public void RemoveWorkspace(string path)
+ {
+ lock (_gate)
+ {
+ using var command = _connection.CreateCommand();
+ command.CommandText = "DELETE FROM workspaces WHERE path = $path;";
+ command.Parameters.AddWithValue("$path", path);
+ command.ExecuteNonQuery();
+ }
+ }
+
+ /// Closes the underlying connection.
+ public void Dispose() => _connection.Dispose();
+}
diff --git a/YKanBan/Storage/SchemaMigration.cs b/YKanBan/Storage/SchemaMigration.cs
new file mode 100644
index 0000000..d2d9c2d
--- /dev/null
+++ b/YKanBan/Storage/SchemaMigration.cs
@@ -0,0 +1,7 @@
+namespace YKanBan.Storage;
+
+/// A single incremental schema step applied when the stored user_version is one below .
+/// Target user_version after applying this migration (contiguous, starting at 1).
+/// Short human-readable identifier used for diagnostics.
+/// SQL script executed inside one transaction together with the user_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..63e29bf
--- /dev/null
+++ b/YKanBan/Storage/SqliteDatabase.cs
@@ -0,0 +1,102 @@
+using Microsoft.Data.Sqlite;
+
+namespace YKanBan.Storage;
+
+///
+/// Shared SQLite plumbing for both the per-workspace databases and the
+/// global app.db: opens connections 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 PRAGMAs and 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,
+ Pooling = false,
+ }.ToString());
+ connection.Open();
+ try
+ {
+ ApplyPragmas(connection);
+ ApplyMigrations(connection, migrations);
+ return connection;
+ }
+ catch
+ {
+ connection.Dispose();
+ throw;
+ }
+ }
+
+ /// Reads the stored PRAGMA user_version value.
+ public static long ReadUserVersion(SqliteConnection connection)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = "PRAGMA user_version;";
+ return (long)command.ExecuteScalar()!;
+ }
+
+ /// Applies per-connection PRAGMAs. WAL itself is a persistent database property.
+ internal static void ApplyPragmas(SqliteConnection connection)
+ {
+ // WAL plus NORMAL synchronous is the chosen durability/performance trade-off;
+ // foreign_keys must be enabled on every connection or DDL-level cascades never fire.
+ 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.
+ /// The database was written by a newer build.
+ /// The migration list is not contiguous from version 1.
+ internal static void ApplyMigrations(SqliteConnection connection, IReadOnlyList migrations)
+ {
+ // Contract check: the list must be contiguous and ordered, starting 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.
+ 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..15a4618
--- /dev/null
+++ b/YKanBan/Storage/StorageExceptions.cs
@@ -0,0 +1,90 @@
+namespace YKanBan.Storage;
+
+///
+/// Typed exceptions thrown by the YKanBan storage and application layers.
+/// Exception messages are always English and never localized; the UI maps
+/// exception types to localized resource keys instead.
+///
+public abstract class YKanBanException : Exception
+{
+ /// Creates an exception with an English diagnostic message.
+ protected YKanBanException(string message, Exception? innerException = null)
+ : base(message, innerException)
+ {
+ }
+}
+
+/// Signals that the workspace folder does not exist on disk.
+public sealed class WorkspaceDirectoryMissingException : YKanBanException
+{
+ /// The folder path that was expected to exist.
+ public string FolderPath { get; }
+
+ /// Creates an exception for a folder that is absent on disk.
+ public WorkspaceDirectoryMissingException(string folderPath)
+ : base($"The folder does not exist: {folderPath}")
+ {
+ FolderPath = folderPath;
+ }
+}
+
+/// Signals that the folder has no .ykanban structure yet.
+public sealed class WorkspaceNotInitializedException : YKanBanException
+{
+ /// The folder path that lacks the .ykanban structure.
+ public string FolderPath { get; }
+
+ /// Creates an exception for a folder without .ykanban.
+ public WorkspaceNotInitializedException(string folderPath)
+ : base($"The folder is not a YKanBan workspace (missing .ykanban): {folderPath}")
+ {
+ FolderPath = folderPath;
+ }
+}
+
+/// Signals that another instance holds the workspace lock.
+public sealed class WorkspaceLockException : YKanBanException
+{
+ /// The lock file that could not be acquired.
+ public string LockFilePath { get; }
+
+ /// Best-effort diagnostics (PID/machine/time) read from the lock file, if readable.
+ public string? HolderDiagnostics { get; }
+
+ /// Creates an exception for a failed lock acquisition.
+ public WorkspaceLockException(string lockFilePath, string? holderDiagnostics, Exception innerException)
+ : base(BuildMessage(lockFilePath, holderDiagnostics), innerException)
+ {
+ LockFilePath = lockFilePath;
+ HolderDiagnostics = holderDiagnostics;
+ }
+
+ /// Composes the message, including holder details when they were readable.
+ 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 stored schema version is not manageable by this build.
+public sealed class SchemaVersionException : YKanBanException
+{
+ /// The user_version value found in the database.
+ public long StoredVersion { get; }
+
+ /// The latest schema version this build understands.
+ public int SupportedVersion { get; }
+
+ /// Creates an exception for an unmanageable schema version.
+ 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..22096a4
--- /dev/null
+++ b/YKanBan/Storage/UnixTime.cs
@@ -0,0 +1,8 @@
+namespace YKanBan.Storage;
+
+/// Provides the current Unix time; all persisted timestamps are INTEGER Unix seconds.
+public static class UnixTime
+{
+ /// Current UTC time 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..87dfbcd
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceInitializer.cs
@@ -0,0 +1,35 @@
+using Microsoft.Data.Sqlite;
+
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Creates the .ykanban structure for a folder: the folder itself and an
+/// empty, fully migrated database. Seeding preset content is a separate
+/// concern handled by .
+///
+public static class WorkspaceInitializer
+{
+ /// Returns true when the folder contains a .ykanban structure (no parent lookup).
+ public static bool IsWorkspace(string folderPath) => Directory.Exists(WorkspacePaths.Root(folderPath));
+
+ /// Initializes a fresh workspace: creates .ykanban and an empty migrated database.
+ /// 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 and the migrated, 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..18cb10d
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceLock.cs
@@ -0,0 +1,72 @@
+using System.Text;
+
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Exclusive, OS-enforced lock over a workspace's ykanban.lock. The handle
+/// is held until disposal; crashes and power loss release it automatically.
+/// The lock file carries PID / machine name / time for human diagnostics
+/// only.
+///
+public sealed class WorkspaceLock : IDisposable
+{
+ private readonly FileStream _stream;
+
+ private WorkspaceLock(FileStream stream, string lockFilePath)
+ {
+ _stream = stream;
+ LockFilePath = lockFilePath;
+ }
+
+ /// Path of the lock file being held.
+ public string LockFilePath { get; }
+
+ /// Acquires the workspace lock or throws .
+ /// The folder has no .ykanban structure.
+ /// Another instance 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 any 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.
+ 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.
+ 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..10a71c4
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspacePaths.cs
@@ -0,0 +1,23 @@
+namespace YKanBan.Storage.Workspace;
+
+/// Well-known file and folder names inside a workspace's .ykanban structure.
+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.
+ public static string Root(string folderPath) => Path.Combine(folderPath, RootFolderName);
+
+ /// Returns the workspace database path for a workspace folder.
+ public static string Database(string folderPath) => Path.Combine(folderPath, RootFolderName, DatabaseFileName);
+
+ /// Returns the workspace lock file path for a workspace folder.
+ public static string LockFile(string folderPath) => Path.Combine(folderPath, RootFolderName, LockFileName);
+}
diff --git a/YKanBan/Storage/Workspace/WorkspacePreset.cs b/YKanBan/Storage/Workspace/WorkspacePreset.cs
new file mode 100644
index 0000000..0885520
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspacePreset.cs
@@ -0,0 +1,46 @@
+using Microsoft.Data.Sqlite;
+
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Seeds initial workspace content. The three preset column titles are read
+/// straight from the ResX resources in the UI language active at seeding
+/// time, then stored as ordinary data that no longer follows language
+/// switches.
+///
+public static class WorkspacePreset
+{
+ /// Inserts the three preset columns (To Do / In Progress / Done equivalents).
+ /// A title collides with an existing column.
+ public static void SeedColumns(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 in one transaction so a half-seeded 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..0daaed1
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceSchema.cs
@@ -0,0 +1,50 @@
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Schema definitions for the per-workspace database. The board is a single
+/// board with a columns → cards hierarchy. All data validation is expressed
+/// in DDL (CHECK / UNIQUE / FOREIGN KEY) so that any row entering the
+/// database is valid by construction; cascading deletes are covered by the
+/// same DDL.
+///
+public static class WorkspaceSchema
+{
+ /// Latest 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[]
+ {
+ new SchemaMigration(1, "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 36a8404..cd0f14f 100644
--- a/YKanBan/YKanBan.csproj
+++ b/YKanBan/YKanBan.csproj
@@ -2,6 +2,7 @@
WinExe
net9.0
+ enable
enable
true
app.manifest
@@ -9,7 +10,24 @@
-
+
+
+
+
+
+
+ MSBuild:Compile
+ $(IntermediateOutputPath)Resources.Designer.cs
+ YKanBan
+ Resources
+ CSharp
+ true
+ YKanBan.Resources.resources
+
+
+ YKanBan.Resources.zh-Hans.resources
+