feat: add storage foundation and i18n resources
This commit is contained in:
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 <path></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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 <path></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 <path></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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user