feat: add storage foundation and i18n resources

This commit is contained in:
yyc12345 committed 2026-10-02 22:05:12 +08:00
1 parent a5edad4aad
commit c2ee02c209
26 files changed
+1846 -1

No files matched your search

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