From 31fc58d9c754666d98cf90fd351c1b2df75d3d8f Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Thu, 1 Oct 2026 18:17:47 +0800 Subject: [PATCH] feat(m1): storage layer - Shared SQLite plumbing: PRAGMAs (WAL/NORMAL/foreign_keys) and user_version migrations with typed SchemaVersionException - Workspace folder: .ykanban initialization, empty schema with DDL-level constraints, preset column seeding (ResX-driven), exclusive lock file with holder diagnostics - AppData folder: app.db workspace registry (injectable clock), app.json config store with kebab-case JSON names via explicit [JsonProperty]/[EnumMember], nested sections, corrupt-file backup - ResX infrastructure under Assets/Locales (en + zh-Hans) with MSBuild strongly-typed public Resources class - 54 unit tests: DDL constraints, migrations, PRAGMAs, lock exclusivity, config degradation, culture-aware presets --- .../Storage/AppData/AppConfigStoreTests.cs | 169 ++++++++++++++++++ .../Storage/AppData/AppDatabaseTests.cs | 136 ++++++++++++++ YKanBan.Tests/Storage/SqliteDatabaseTests.cs | 114 ++++++++++++ .../Workspace/PresetColumnCultureTests.cs | 59 ++++++ .../Workspace/WorkspaceInitializerTests.cs | 53 ++++++ .../Storage/Workspace/WorkspaceLockTests.cs | 57 ++++++ .../Storage/Workspace/WorkspacePresetTests.cs | 39 ++++ .../Storage/Workspace/WorkspaceSchemaTests.cs | 160 +++++++++++++++++ .../TestUtilities/SqliteTestHelper.cs | 67 +++++++ YKanBan.Tests/TestUtilities/TempDirectory.cs | 31 ++++ YKanBan/Assets/Locales/Resources.resx | 24 +++ YKanBan/Assets/Locales/Resources.zh-Hans.resx | 24 +++ YKanBan/Storage/AppData/AppConfig.cs | 156 ++++++++++++++++ YKanBan/Storage/AppData/AppConfigStore.cs | 74 ++++++++ YKanBan/Storage/AppData/AppDatabase.cs | 122 +++++++++++++ YKanBan/Storage/SchemaMigration.cs | 7 + YKanBan/Storage/SqliteDatabase.cs | 102 +++++++++++ YKanBan/Storage/StorageExceptions.cs | 90 ++++++++++ YKanBan/Storage/UnixTime.cs | 8 + .../Storage/Workspace/WorkspaceInitializer.cs | 35 ++++ YKanBan/Storage/Workspace/WorkspaceLock.cs | 72 ++++++++ YKanBan/Storage/Workspace/WorkspacePaths.cs | 23 +++ YKanBan/Storage/Workspace/WorkspacePreset.cs | 46 +++++ YKanBan/Storage/Workspace/WorkspaceSchema.cs | 50 ++++++ YKanBan/YKanBan.csproj | 20 ++- 25 files changed, 1737 insertions(+), 1 deletion(-) create mode 100644 YKanBan.Tests/Storage/AppData/AppConfigStoreTests.cs create mode 100644 YKanBan.Tests/Storage/AppData/AppDatabaseTests.cs create mode 100644 YKanBan.Tests/Storage/SqliteDatabaseTests.cs create mode 100644 YKanBan.Tests/Storage/Workspace/PresetColumnCultureTests.cs create mode 100644 YKanBan.Tests/Storage/Workspace/WorkspaceInitializerTests.cs create mode 100644 YKanBan.Tests/Storage/Workspace/WorkspaceLockTests.cs create mode 100644 YKanBan.Tests/Storage/Workspace/WorkspacePresetTests.cs create mode 100644 YKanBan.Tests/Storage/Workspace/WorkspaceSchemaTests.cs create mode 100644 YKanBan.Tests/TestUtilities/SqliteTestHelper.cs create mode 100644 YKanBan.Tests/TestUtilities/TempDirectory.cs create mode 100644 YKanBan/Assets/Locales/Resources.resx create mode 100644 YKanBan/Assets/Locales/Resources.zh-Hans.resx create mode 100644 YKanBan/Storage/AppData/AppConfig.cs create mode 100644 YKanBan/Storage/AppData/AppConfigStore.cs create mode 100644 YKanBan/Storage/AppData/AppDatabase.cs create mode 100644 YKanBan/Storage/SchemaMigration.cs create mode 100644 YKanBan/Storage/SqliteDatabase.cs create mode 100644 YKanBan/Storage/StorageExceptions.cs create mode 100644 YKanBan/Storage/UnixTime.cs create mode 100644 YKanBan/Storage/Workspace/WorkspaceInitializer.cs create mode 100644 YKanBan/Storage/Workspace/WorkspaceLock.cs create mode 100644 YKanBan/Storage/Workspace/WorkspacePaths.cs create mode 100644 YKanBan/Storage/Workspace/WorkspacePreset.cs create mode 100644 YKanBan/Storage/Workspace/WorkspaceSchema.cs 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 +