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
This commit is contained in:
yyc12345 committed 2026-10-01 18:17:47 +08:00
1 parent e934eb9629
commit 31fc58d9c7
25 files changed
+1737 -1

No files matched your search

@@ -0,0 +1,169 @@
using YKanBan.Storage.AppData;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.AppData;
/// <summary>Tests for app.json load/save and its degradation rules.</summary>
[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);
}
}
@@ -0,0 +1,136 @@
using Microsoft.Data.Sqlite;
using YKanBan.Storage;
using YKanBan.Storage.AppData;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.AppData;
/// <summary>Tests for the global app.db workspace registry.</summary>
[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<WorkspaceRecord> 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;"));
}
}
@@ -0,0 +1,114 @@
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage;
/// <summary>Tests for PRAGMA application and user_version migration machinery.</summary>
[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<SchemaVersionException>(
() => 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<ArgumentException>(() => SqliteDatabase.Open(dbPath, bad));
}
}
@@ -0,0 +1,59 @@
using System.Globalization;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.Workspace;
/// <summary>
/// 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.
/// </summary>
[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;
}
}
}
@@ -0,0 +1,53 @@
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.Workspace;
/// <summary>Tests for .ykanban folder and empty database creation.</summary>
[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<WorkspaceDirectoryMissingException>(() => WorkspaceInitializer.Initialize(missing));
}
[TestMethod]
public void InitializeThrowsWhenDatabaseAlreadyExists()
{
using var directory = new TempDirectory();
WorkspaceInitializer.Initialize(directory.FullPath);
Assert.ThrowsExactly<InvalidOperationException>(() => WorkspaceInitializer.Initialize(directory.FullPath));
}
}
@@ -0,0 +1,57 @@
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.Workspace;
/// <summary>Tests for exclusive lock acquisition, diagnostics and release.</summary>
[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<WorkspaceLockException>(
() => WorkspaceLock.Acquire(directory.FullPath));
Assert.AreEqual(WorkspacePaths.LockFile(directory.FullPath), ex.LockFilePath);
}
[TestMethod]
public void AcquireWithoutYkanbanThrowsNotInitialized()
{
using var directory = new TempDirectory();
Assert.ThrowsExactly<WorkspaceNotInitializedException>(
() => 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}");
}
}
@@ -0,0 +1,39 @@
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.Workspace;
/// <summary>Tests for seeding preset workspace content.</summary>
[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<string> 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;"));
}
}
@@ -0,0 +1,160 @@
using Microsoft.Data.Sqlite;
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Storage.Workspace;
/// <summary>Verifies that the DDL constraints reject invalid data at the door.</summary>
[TestClass]
public class WorkspaceSchemaTests
{
/// <summary>Owns a temp directory plus a fresh workspace-schema database for one test.</summary>
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<SqliteException>(() =>
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<SqliteException>(() =>
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<SqliteException>(() =>
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<SqliteException>(() =>
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<SqliteException>(() =>
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<SqliteException>(() =>
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<SqliteException>(() =>
SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);"));
}
}
@@ -0,0 +1,67 @@
using Microsoft.Data.Sqlite;
using YKanBan.Storage;
using YKanBan.Storage.Workspace;
namespace YKanBan.Tests.TestUtilities;
/// <summary>Shared helpers for storage tests.</summary>
public static class SqliteTestHelper
{
/// <summary>Executes a non-query SQL statement and returns the affected row count.</summary>
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();
}
/// <summary>Returns the scalar result of a SQL query as a string.</summary>
public static string ScalarString(SqliteConnection connection, string sql)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
return (string)command.ExecuteScalar()!;
}
/// <summary>Returns the scalar result of a SQL query as a long.</summary>
public static long ScalarLong(SqliteConnection connection, string sql)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
return (long)command.ExecuteScalar()!;
}
/// <summary>Returns true when a table with the given name exists.</summary>
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;
}
/// <summary>Returns the titles of all columns, ordered by id.</summary>
public static List<string> 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<string>();
while (reader.Read())
{
titles.Add(reader.GetString(0));
}
return titles;
}
/// <summary>Opens a workspace database with the real migration list.</summary>
public static SqliteConnection OpenWorkspace(string folderPath)
{
return SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
}
}
@@ -0,0 +1,31 @@
namespace YKanBan.Tests.TestUtilities;
/// <summary>
/// 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.
/// </summary>
public sealed class TempDirectory : IDisposable
{
/// <summary>Unique directory path managed by this instance.</summary>
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) { }
}
}
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="PresetColumn_Todo" xml:space="preserve">
<value>To Do</value>
</data>
<data name="PresetColumn_InProgress" xml:space="preserve">
<value>In Progress</value>
</data>
<data name="PresetColumn_Done" xml:space="preserve">
<value>Done</value>
</data>
</root>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="PresetColumn_Todo" xml:space="preserve">
<value>待办</value>
</data>
<data name="PresetColumn_InProgress" xml:space="preserve">
<value>进行中</value>
</data>
<data name="PresetColumn_Done" xml:space="preserve">
<value>已完成</value>
</data>
</root>
+156
View File
@@ -0,0 +1,156 @@
using System.Runtime.Serialization;
using Newtonsoft.Json;
namespace YKanBan.Storage.AppData;
/// <summary>Card ordering options. All orders fall back to id ascending.</summary>
public enum CardSortOption
{
/// <summary>Ascending primary key (default).</summary>
[EnumMember(Value = "id")]
Id,
/// <summary>Creation time, oldest first.</summary>
[EnumMember(Value = "created-at")]
CreatedAt,
/// <summary>Last modification time, most recent first.</summary>
[EnumMember(Value = "updated-at")]
UpdatedAt,
/// <summary>Title by code points (BINARY collation); empty titles sort first.</summary>
[EnumMember(Value = "title")]
Title,
}
/// <summary>Sidebar workspace list ordering.</summary>
public enum SidebarSortOption
{
/// <summary>Folder name A to Z.</summary>
[EnumMember(Value = "name-asc")]
NameAsc,
/// <summary>Folder name Z to A.</summary>
[EnumMember(Value = "name-desc")]
NameDesc,
/// <summary>By workspace last edit time, most recent first.</summary>
[EnumMember(Value = "recently-updated")]
RecentlyUpdated,
}
/// <summary>Column width presets. Custom uses a user-defined pixel width.</summary>
public enum ColumnWidthPreset
{
/// <summary>Narrow preset.</summary>
[EnumMember(Value = "narrow")]
Narrow,
/// <summary>Standard preset (320 px, the default).</summary>
[EnumMember(Value = "standard")]
Standard,
/// <summary>Wide preset.</summary>
[EnumMember(Value = "wide")]
Wide,
/// <summary>User-defined pixel width (160–720).</summary>
[EnumMember(Value = "custom")]
Custom,
}
/// <summary>UI theme selection.</summary>
public enum ThemeOption
{
/// <summary>Always light.</summary>
[EnumMember(Value = "light")]
Light,
/// <summary>Always dark.</summary>
[EnumMember(Value = "dark")]
Dark,
/// <summary>Follow the operating system setting (default).</summary>
[EnumMember(Value = "follow-system")]
FollowSystem,
}
/// <summary>Card and sidebar list ordering settings.</summary>
public sealed class SortSettings
{
/// <summary>Card order used by all workspaces (global, not per workspace).</summary>
[JsonProperty("card")]
public CardSortOption Card { get; set; } = CardSortOption.Id;
/// <summary>Sidebar workspace list order.</summary>
[JsonProperty("sidebar")]
public SidebarSortOption Sidebar { get; set; } = SidebarSortOption.NameAsc;
}
/// <summary>Board column width settings.</summary>
public sealed class ColumnWidthSettings
{
/// <summary>Width preset applied globally.</summary>
[JsonProperty("preset")]
public ColumnWidthPreset Preset { get; set; } = ColumnWidthPreset.Standard;
/// <summary>Manual width in pixels used when <see cref="Preset"/> is Custom (range 160–720).</summary>
[JsonProperty("custom-pixels")]
public int CustomPixels { get; set; } = 320;
}
/// <summary>Confirmation-dialog toggles for destructive actions.</summary>
public sealed class ConfirmationSettings
{
/// <summary>Whether deleting a card asks for confirmation.</summary>
[JsonProperty("delete-card")]
public bool DeleteCard { get; set; } = true;
/// <summary>Whether deleting a column asks for confirmation.</summary>
[JsonProperty("delete-column")]
public bool DeleteColumn { get; set; } = true;
/// <summary>Whether deleting a tag asks for confirmation.</summary>
[JsonProperty("delete-tag")]
public bool DeleteTag { get; set; } = true;
/// <summary>Whether discarding card edits asks for confirmation.</summary>
[JsonProperty("discard-edit")]
public bool DiscardEdit { get; set; } = true;
}
/// <summary>
/// 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.
/// </summary>
public sealed class AppConfig
{
/// <summary>Current configuration format version.</summary>
public const int CurrentFormatVersion = 1;
/// <summary>Configuration format version of this file.</summary>
[JsonProperty("version")]
public int Version { get; set; } = CurrentFormatVersion;
/// <summary>UI language code: "en" (default) or "zh-Hans".</summary>
[JsonProperty("language")]
public string Language { get; set; } = "en";
/// <summary>UI theme selection (default: follow the system).</summary>
[JsonProperty("theme")]
public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem;
/// <summary>Ordering settings.</summary>
[JsonProperty("sort")]
public SortSettings Sort { get; set; } = new();
/// <summary>Column width settings.</summary>
[JsonProperty("column-width")]
public ColumnWidthSettings ColumnWidth { get; set; } = new();
/// <summary>Confirmation-dialog settings.</summary>
[JsonProperty("confirmations")]
public ConfirmationSettings Confirmations { get; set; } = new();
}
+74
View File
@@ -0,0 +1,74 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace YKanBan.Storage.AppData;
/// <summary>
/// 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.
/// </summary>
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() },
};
/// <summary>Creates a store bound to a specific app.json path.</summary>
public AppConfigStore(string filePath) => FilePath = filePath;
/// <summary>Full path of the managed app.json file.</summary>
public string FilePath { get; }
/// <summary>Loads the configuration: missing file → defaults; corrupt file → backup then defaults.</summary>
/// <exception cref="IOException">The file exists but cannot be read.</exception>
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<AppConfig>(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();
}
}
/// <summary>Writes the configuration directly to disk (no atomic replace).</summary>
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));
}
}
+122
View File
@@ -0,0 +1,122 @@
using Microsoft.Data.Sqlite;
namespace YKanBan.Storage.AppData;
/// <summary>A known workspace row of the global app.db.</summary>
/// <param name="Path">Workspace folder path as registered.</param>
/// <param name="LastEditedAt">Unix seconds of the last successful data write to the workspace database.</param>
/// <param name="AddedAt">Unix seconds when the workspace was registered.</param>
public sealed record WorkspaceRecord(string Path, long LastEditedAt, long AddedAt);
/// <summary>
/// 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.
/// </summary>
public sealed class AppDatabase : IDisposable
{
/// <summary>Latest app.db schema version this build understands.</summary>
public const int CurrentSchemaVersion = 1;
private static readonly IReadOnlyList<SchemaMigration> 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<long> _unixNow;
private readonly object _gate = new();
/// <summary>Opens (creating if needed) the app.db file and applies pending migrations.</summary>
/// <param name="databasePath">Path of the app.db file.</param>
/// <param name="unixNow">Clock override for tests; defaults to real Unix time.</param>
public AppDatabase(string databasePath, Func<long>? 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);
}
/// <summary>Returns all known workspaces ordered by added_at ascending.</summary>
public IReadOnlyList<WorkspaceRecord> 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<WorkspaceRecord>();
while (reader.Read())
{
records.Add(new WorkspaceRecord(reader.GetString(0), reader.GetInt64(1), reader.GetInt64(2)));
}
return records;
}
}
/// <summary>Registers a workspace, or refreshes last_edited_at if it is already known.</summary>
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();
}
}
/// <summary>Refreshes last_edited_at for a known workspace; registers it if the row is absent.</summary>
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);
}
}
}
/// <summary>Forgets a workspace entry; the .ykanban folder itself is untouched.</summary>
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();
}
}
/// <summary>Closes the underlying connection.</summary>
public void Dispose() => _connection.Dispose();
}
+7
View File
@@ -0,0 +1,7 @@
namespace YKanBan.Storage;
/// <summary>A single incremental schema step applied when the stored user_version is one below <see cref="Version"/>.</summary>
/// <param name="Version">Target user_version after applying this migration (contiguous, starting at 1).</param>
/// <param name="Name">Short human-readable identifier used for diagnostics.</param>
/// <param name="Sql">SQL script executed inside one transaction together with the user_version bump.</param>
public sealed record SchemaMigration(int Version, string Name, string Sql);
+102
View File
@@ -0,0 +1,102 @@
using Microsoft.Data.Sqlite;
namespace YKanBan.Storage;
/// <summary>
/// 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.
/// </summary>
public static class SqliteDatabase
{
/// <summary>Opens (creating if needed) a database file, applies PRAGMAs and pending migrations.</summary>
/// <param name="databasePath">Path of the SQLite database file.</param>
/// <param name="migrations">Contiguous migration list, ordered by version starting at 1.</param>
/// <returns>An open connection with all PRAGMAs applied and the schema migrated.</returns>
public static SqliteConnection Open(string databasePath, IReadOnlyList<SchemaMigration> 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;
}
}
/// <summary>Reads the stored PRAGMA user_version value.</summary>
public static long ReadUserVersion(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = "PRAGMA user_version;";
return (long)command.ExecuteScalar()!;
}
/// <summary>Applies per-connection PRAGMAs. WAL itself is a persistent database property.</summary>
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;");
}
/// <summary>Runs every migration newer than the stored user_version, each in its own transaction.</summary>
/// <exception cref="SchemaVersionException">The database was written by a newer build.</exception>
/// <exception cref="ArgumentException">The migration list is not contiguous from version 1.</exception>
internal static void ApplyMigrations(SqliteConnection connection, IReadOnlyList<SchemaMigration> 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();
}
}
/// <summary>Executes a non-query SQL script, optionally inside an explicit transaction.</summary>
private static void Execute(SqliteConnection connection, string commandText, SqliteTransaction? transaction = null)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = commandText;
command.ExecuteNonQuery();
}
}
+90
View File
@@ -0,0 +1,90 @@
namespace YKanBan.Storage;
/// <summary>
/// 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.
/// </summary>
public abstract class YKanBanException : Exception
{
/// <summary>Creates an exception with an English diagnostic message.</summary>
protected YKanBanException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
/// <summary>Signals that the workspace folder does not exist on disk.</summary>
public sealed class WorkspaceDirectoryMissingException : YKanBanException
{
/// <summary>The folder path that was expected to exist.</summary>
public string FolderPath { get; }
/// <summary>Creates an exception for a folder that is absent on disk.</summary>
public WorkspaceDirectoryMissingException(string folderPath)
: base($"The folder does not exist: {folderPath}")
{
FolderPath = folderPath;
}
}
/// <summary>Signals that the folder has no .ykanban structure yet.</summary>
public sealed class WorkspaceNotInitializedException : YKanBanException
{
/// <summary>The folder path that lacks the .ykanban structure.</summary>
public string FolderPath { get; }
/// <summary>Creates an exception for a folder without .ykanban.</summary>
public WorkspaceNotInitializedException(string folderPath)
: base($"The folder is not a YKanBan workspace (missing .ykanban): {folderPath}")
{
FolderPath = folderPath;
}
}
/// <summary>Signals that another instance holds the workspace lock.</summary>
public sealed class WorkspaceLockException : YKanBanException
{
/// <summary>The lock file that could not be acquired.</summary>
public string LockFilePath { get; }
/// <summary>Best-effort diagnostics (PID/machine/time) read from the lock file, if readable.</summary>
public string? HolderDiagnostics { get; }
/// <summary>Creates an exception for a failed lock acquisition.</summary>
public WorkspaceLockException(string lockFilePath, string? holderDiagnostics, Exception innerException)
: base(BuildMessage(lockFilePath, holderDiagnostics), innerException)
{
LockFilePath = lockFilePath;
HolderDiagnostics = holderDiagnostics;
}
/// <summary>Composes the message, including holder details when they were readable.</summary>
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;
}
}
/// <summary>Signals that the stored schema version is not manageable by this build.</summary>
public sealed class SchemaVersionException : YKanBanException
{
/// <summary>The user_version value found in the database.</summary>
public long StoredVersion { get; }
/// <summary>The latest schema version this build understands.</summary>
public int SupportedVersion { get; }
/// <summary>Creates an exception for an unmanageable schema version.</summary>
public SchemaVersionException(long storedVersion, int supportedVersion)
: base($"Database schema version {storedVersion} is newer than the latest supported version {supportedVersion}.")
{
StoredVersion = storedVersion;
SupportedVersion = supportedVersion;
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace YKanBan.Storage;
/// <summary>Provides the current Unix time; all persisted timestamps are INTEGER Unix seconds.</summary>
public static class UnixTime
{
/// <summary>Current UTC time as Unix seconds.</summary>
public static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
@@ -0,0 +1,35 @@
using Microsoft.Data.Sqlite;
namespace YKanBan.Storage.Workspace;
/// <summary>
/// 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 <see cref="WorkspacePreset"/>.
/// </summary>
public static class WorkspaceInitializer
{
/// <summary>Returns true when the folder contains a .ykanban structure (no parent lookup).</summary>
public static bool IsWorkspace(string folderPath) => Directory.Exists(WorkspacePaths.Root(folderPath));
/// <summary>Initializes a fresh workspace: creates .ykanban and an empty migrated database.</summary>
/// <exception cref="WorkspaceDirectoryMissingException">The folder does not exist on disk.</exception>
/// <exception cref="InvalidOperationException">The workspace database already exists.</exception>
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);
}
}
@@ -0,0 +1,72 @@
using System.Text;
namespace YKanBan.Storage.Workspace;
/// <summary>
/// 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.
/// </summary>
public sealed class WorkspaceLock : IDisposable
{
private readonly FileStream _stream;
private WorkspaceLock(FileStream stream, string lockFilePath)
{
_stream = stream;
LockFilePath = lockFilePath;
}
/// <summary>Path of the lock file being held.</summary>
public string LockFilePath { get; }
/// <summary>Acquires the workspace lock or throws <see cref="WorkspaceLockException"/>.</summary>
/// <exception cref="WorkspaceNotInitializedException">The folder has no .ykanban structure.</exception>
/// <exception cref="WorkspaceLockException">Another instance holds the lock.</exception>
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);
}
/// <summary>Attempts to read holder diagnostics from a lock file we failed to acquire.</summary>
private static string? ReadDiagnosticsBestEffort(string lockFilePath)
{
try
{
return File.ReadAllText(lockFilePath);
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
/// <summary>Releases the exclusive handle. The lock file itself is intentionally left on disk.</summary>
public void Dispose() => _stream.Dispose();
}
@@ -0,0 +1,23 @@
namespace YKanBan.Storage.Workspace;
/// <summary>Well-known file and folder names inside a workspace's .ykanban structure.</summary>
public static class WorkspacePaths
{
/// <summary>Name of the workspace root folder created inside a managed project.</summary>
public const string RootFolderName = ".ykanban";
/// <summary>Name of the SQLite database inside .ykanban.</summary>
public const string DatabaseFileName = "ykanban.db";
/// <summary>Name of the exclusive lock file inside .ykanban.</summary>
public const string LockFileName = "ykanban.lock";
/// <summary>Returns the .ykanban root path for a workspace folder.</summary>
public static string Root(string folderPath) => Path.Combine(folderPath, RootFolderName);
/// <summary>Returns the workspace database path for a workspace folder.</summary>
public static string Database(string folderPath) => Path.Combine(folderPath, RootFolderName, DatabaseFileName);
/// <summary>Returns the workspace lock file path for a workspace folder.</summary>
public static string LockFile(string folderPath) => Path.Combine(folderPath, RootFolderName, LockFileName);
}
@@ -0,0 +1,46 @@
using Microsoft.Data.Sqlite;
namespace YKanBan.Storage.Workspace;
/// <summary>
/// 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.
/// </summary>
public static class WorkspacePreset
{
/// <summary>Inserts the three preset columns (To Do / In Progress / Done equivalents).</summary>
/// <exception cref="SqliteException">A title collides with an existing column.</exception>
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();
}
}
@@ -0,0 +1,50 @@
namespace YKanBan.Storage.Workspace;
/// <summary>
/// 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.
/// </summary>
public static class WorkspaceSchema
{
/// <summary>Latest workspace schema version this build understands.</summary>
public const int CurrentVersion = 1;
/// <summary>Ordered, contiguous migration list for the workspace database.</summary>
public static readonly IReadOnlyList<SchemaMigration> 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)
);
"""),
};
}
+19 -1
View File
@@ -2,6 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
@@ -9,7 +10,24 @@
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<AvaloniaResource Include="Assets\**" Exclude="Assets\Locales\**" />
</ItemGroup>
<!--Strongly-typed ResX resources generated by the cross-platform MSBuild generator.
LogicalName is pinned so the manifest names stay stable despite the Assets/Locales location.-->
<ItemGroup>
<EmbeddedResource Update="Assets/Locales/Resources.resx">
<Generator>MSBuild:Compile</Generator>
<StronglyTypedFileName>$(IntermediateOutputPath)Resources.Designer.cs</StronglyTypedFileName>
<StronglyTypedNamespace>YKanBan</StronglyTypedNamespace>
<StronglyTypedClassName>Resources</StronglyTypedClassName>
<StronglyTypedLanguage>CSharp</StronglyTypedLanguage>
<PublicClass>true</PublicClass>
<LogicalName>YKanBan.Resources.resources</LogicalName>
</EmbeddedResource>
<EmbeddedResource Update="Assets/Locales/Resources.zh-Hans.resx">
<LogicalName>YKanBan.Resources.zh-Hans.resources</LogicalName>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>