diff --git a/YKanBan.Tests/Models/RgbColorTests.cs b/YKanBan.Tests/Models/RgbColorTests.cs
new file mode 100644
index 0000000..35db145
--- /dev/null
+++ b/YKanBan.Tests/Models/RgbColorTests.cs
@@ -0,0 +1,77 @@
+using YKanBan.Models;
+
+namespace YKanBan.Tests.Models;
+
+///
+/// Tests for the RGB color value type: component storage and the text
+/// boundary in both directions.
+///
+[TestClass]
+public class RgbColorTests
+{
+ [TestMethod]
+ public void ParseSplitsComponents()
+ {
+ RgbColor color = RgbColor.Parse("#102030");
+ Assert.AreEqual((byte)0x10, color.R);
+ Assert.AreEqual((byte)0x20, color.G);
+ Assert.AreEqual((byte)0x30, color.B);
+ }
+
+ [TestMethod]
+ [DataRow("#a1B2c3")]
+ [DataRow("#A1B2C3")]
+ [DataRow("#a1b2c3")]
+ public void ParseIsCaseInsensitive(string text)
+ {
+ Assert.AreEqual(new RgbColor(0xA1, 0xB2, 0xC3), RgbColor.Parse(text));
+ }
+
+ [TestMethod]
+ [DataRow("")]
+ [DataRow("112233")]
+ [DataRow("#11223")]
+ [DataRow("#1122334")]
+ [DataRow("#GGGGGG")]
+ [DataRow("#12345g")]
+ [DataRow(" #112233")]
+ [DataRow("#112233 ")]
+ public void ParseRejectsMalformedText(string text)
+ {
+ Assert.ThrowsExactly(() => RgbColor.Parse(text));
+ }
+
+ [TestMethod]
+ public void TryParseRejectsNull()
+ {
+ Assert.IsFalse(RgbColor.TryParse(null, out RgbColor color));
+ Assert.AreEqual(default, color);
+ }
+
+ [TestMethod]
+ public void TryParseAcceptsValidText()
+ {
+ Assert.IsTrue(RgbColor.TryParse("#010203", out RgbColor color));
+ Assert.AreEqual(new RgbColor(1, 2, 3), color);
+ }
+
+ [TestMethod]
+ public void ToStringUsesCanonicalLowercase()
+ {
+ Assert.AreEqual("#ff0a00", new RgbColor(0xFF, 0x0A, 0x00).ToString());
+ }
+
+ [TestMethod]
+ public void TextRoundTrips()
+ {
+ var color = new RgbColor(0x12, 0xAB, 0xCD);
+ Assert.AreEqual(color, RgbColor.Parse(color.ToString()));
+ }
+
+ [TestMethod]
+ public void EqualityComparesComponents()
+ {
+ Assert.AreEqual(new RgbColor(1, 2, 3), new RgbColor(1, 2, 3));
+ Assert.AreNotEqual(new RgbColor(1, 2, 3), new RgbColor(1, 2, 4));
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs
new file mode 100644
index 0000000..0771973
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs
@@ -0,0 +1,276 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Models;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+///
+/// Tests for workspace CRUD, the three cascade chains, edit-confirm
+/// transaction atomicity and the timestamp refresh rules.
+///
+[TestClass]
+public class WorkspaceRepositoryTests
+{
+ ///
+ /// Owns a workspace with fixed-timestamp fixture data (one column, one
+ /// card, two tags linked to the card) plus the repository under test.
+ /// Fixture timestamps are small literal values so that any real write,
+ /// which stamps the current Unix time, is observable.
+ ///
+ private sealed class RepositoryFixture : IDisposable
+ {
+ public TempDirectory Directory { get; } = new();
+
+ public WorkspaceRepository Repository { get; }
+
+ public long ColumnId { get; } = 1;
+
+ public long CardId { get; } = 1;
+
+ public long BugTagId { get; } = 1;
+
+ public long UiTagId { get; } = 2;
+
+ public RepositoryFixture()
+ {
+ WorkspaceInitializer.Initialize(Directory.FullPath);
+
+ using (SqliteConnection connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath))
+ {
+ SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('Col', '', 100, 100);");
+ SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, 'T', 'C', 100, 100);");
+ SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('bug', '#ff0000', '');");
+ SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('ui', '#00ff00', '');");
+ SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);");
+ SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 2);");
+ }
+
+ Repository = new WorkspaceRepository(Directory.FullPath);
+ }
+
+ public void Dispose()
+ {
+ Repository.Dispose();
+ Directory.Dispose();
+ }
+ }
+
+ [TestMethod]
+ public void GetColumnsReturnsTheSeededColumn()
+ {
+ using var fixture = new RepositoryFixture();
+ ColumnModel column = fixture.Repository.GetColumn(fixture.ColumnId)!;
+ Assert.AreEqual("Col", column.Title);
+ Assert.AreEqual(1, fixture.Repository.GetColumns().Count);
+ }
+
+ [TestMethod]
+ public void AddColumnSetsEqualTimestamps()
+ {
+ using var fixture = new RepositoryFixture();
+ ColumnModel column = fixture.Repository.AddColumn("New", "desc");
+ Assert.IsTrue(column.CreatedAt > 0L);
+ Assert.AreEqual(column.CreatedAt, column.UpdatedAt);
+ }
+
+ [TestMethod]
+ public void UpdateColumnWithoutChangeWritesNothing()
+ {
+ using var fixture = new RepositoryFixture();
+
+ ColumnModel result = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col", "");
+
+ // A no-op must not refresh the fixed fixture timestamp.
+ Assert.AreEqual(100L, result.UpdatedAt);
+ }
+
+ [TestMethod]
+ public void UpdateColumnChangeRefreshesTimestamp()
+ {
+ using var fixture = new RepositoryFixture();
+
+ ColumnModel updated = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col2", "desc");
+
+ Assert.IsTrue(updated.UpdatedAt > 100L);
+ Assert.AreEqual(100L, updated.CreatedAt);
+ }
+
+ [TestMethod]
+ public void DeleteColumnCascadesToCardsAndTagLinks()
+ {
+ using var fixture = new RepositoryFixture();
+
+ Assert.IsTrue(fixture.Repository.DeleteColumn(fixture.ColumnId));
+
+ Assert.AreEqual(0, fixture.Repository.GetCards().Count);
+ // The tags themselves survive the column deletion.
+ Assert.AreEqual(2, fixture.Repository.GetTags().Count);
+ using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath);
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM card_tags;"));
+ }
+
+ [TestMethod]
+ public void DeleteCardRemovesTagLinksButKeepsTags()
+ {
+ using var fixture = new RepositoryFixture();
+
+ Assert.IsTrue(fixture.Repository.DeleteCard(fixture.CardId));
+
+ Assert.IsNull(fixture.Repository.GetCard(fixture.CardId));
+ Assert.AreEqual(2, fixture.Repository.GetTags().Count);
+ using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath);
+ Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM card_tags;"));
+ }
+
+ [TestMethod]
+ public void DeleteTagDetachesFromCardsButKeepsCards()
+ {
+ using var fixture = new RepositoryFixture();
+
+ Assert.IsTrue(fixture.Repository.DeleteTag(fixture.BugTagId));
+
+ Assert.AreEqual(1, fixture.Repository.GetTags().Count);
+ CardModel card = fixture.Repository.GetCard(fixture.CardId)!;
+ Assert.AreEqual(1, card.Tags.Count);
+ Assert.AreEqual("ui", card.Tags[0].Name);
+ }
+
+ [TestMethod]
+ public void UpdateCardWithoutEffectiveChangeWritesNothing()
+ {
+ using var fixture = new RepositoryFixture();
+
+ CardModel result = fixture.Repository.UpdateCard(
+ fixture.CardId, "T", "C", [fixture.BugTagId, fixture.UiTagId]);
+
+ Assert.AreEqual(100L, result.UpdatedAt);
+ }
+
+ [TestMethod]
+ public void UpdateCardTitleChangeRefreshesTimestamp()
+ {
+ using var fixture = new RepositoryFixture();
+
+ CardModel updated = fixture.Repository.UpdateCard(
+ fixture.CardId, "T2", "C", [fixture.BugTagId, fixture.UiTagId]);
+
+ Assert.IsTrue(updated.UpdatedAt > 100L);
+ Assert.AreEqual(100L, updated.CreatedAt);
+ }
+
+ [TestMethod]
+ public void UpdateCardTagSetOnlyChangeStillRefreshesTimestamp()
+ {
+ using var fixture = new RepositoryFixture();
+
+ CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId]);
+
+ Assert.IsTrue(updated.UpdatedAt > 100L);
+ Assert.AreEqual(1, updated.Tags.Count);
+ }
+
+ [TestMethod]
+ public void UpdateCardWithUnknownTagRollsBackAtomically()
+ {
+ using var fixture = new RepositoryFixture();
+
+ // Tag 999 does not exist: the FK fires mid-transaction and the whole edit must roll back.
+ Assert.ThrowsExactly(() =>
+ fixture.Repository.UpdateCard(fixture.CardId, "TX", "CX", [fixture.BugTagId, 999]));
+
+ CardModel after = fixture.Repository.GetCard(fixture.CardId)!;
+ Assert.AreEqual("T", after.Title);
+ Assert.AreEqual("C", after.Content);
+ Assert.AreEqual(100L, after.UpdatedAt);
+ Assert.AreEqual(2, after.Tags.Count);
+ }
+
+ [TestMethod]
+ public void MoveCardUpdatesColumnWithoutRefreshingTimestamp()
+ {
+ using var fixture = new RepositoryFixture();
+ long target = fixture.Repository.AddColumn("Target", "").Id;
+
+ Assert.IsTrue(fixture.Repository.MoveCard(fixture.CardId, target));
+
+ CardModel moved = fixture.Repository.GetCard(fixture.CardId)!;
+ Assert.AreEqual(target, moved.ColumnId);
+ // The move is a write but deliberately leaves the card's updated_at alone.
+ Assert.AreEqual(100L, moved.UpdatedAt);
+ }
+
+ [TestMethod]
+ public void MoveCardToCurrentColumnIsNoOp()
+ {
+ using var fixture = new RepositoryFixture();
+
+ Assert.IsFalse(fixture.Repository.MoveCard(fixture.CardId, fixture.ColumnId));
+
+ Assert.AreEqual(100L, fixture.Repository.GetCard(fixture.CardId)!.UpdatedAt);
+ }
+
+ [TestMethod]
+ public void AddCardCreatesWithEqualTimestampsAndLinkedTags()
+ {
+ using var fixture = new RepositoryFixture();
+
+ CardModel card = fixture.Repository.AddCard(fixture.ColumnId, "N", "body", [fixture.UiTagId]);
+
+ Assert.IsTrue(card.CreatedAt > 0L);
+ Assert.AreEqual(card.CreatedAt, card.UpdatedAt);
+ Assert.AreEqual(1, card.Tags.Count);
+ Assert.AreEqual("ui", card.Tags[0].Name);
+ }
+
+ [TestMethod]
+ public void UpdateTagWithoutChangeWritesNothing()
+ {
+ using var fixture = new RepositoryFixture();
+
+ TagModel result = fixture.Repository.UpdateTag(
+ fixture.BugTagId, "bug", RgbColor.Parse("#ff0000"), "");
+
+ Assert.AreEqual("bug", result.Name);
+ Assert.AreEqual(new RgbColor(0xFF, 0x00, 0x00), result.Color);
+ }
+
+ [TestMethod]
+ public void UpdateTagChangeWrites()
+ {
+ using var fixture = new RepositoryFixture();
+
+ TagModel updated = fixture.Repository.UpdateTag(
+ fixture.BugTagId, "defect", RgbColor.Parse("#ee0a0a"), "desc");
+
+ Assert.AreEqual("defect", updated.Name);
+ Assert.AreEqual(new RgbColor(0xEE, 0x0A, 0x0A), updated.Color);
+ Assert.AreEqual("desc", updated.Description);
+ Assert.AreEqual("defect", fixture.Repository.GetTags().Single(tag => tag.Id == fixture.BugTagId).Name);
+ }
+
+ [TestMethod]
+ public void AddTagWithDuplicateNameIsRejected()
+ {
+ using var fixture = new RepositoryFixture();
+ Assert.ThrowsExactly(() =>
+ fixture.Repository.AddTag("bug", RgbColor.Parse("#123456"), ""));
+ }
+
+ [TestMethod]
+ public void GetTagsWithUsageCountsAssignments()
+ {
+ using var fixture = new RepositoryFixture();
+
+ IReadOnlyList<(TagModel Tag, long UsageCount)> before = fixture.Repository.GetTagsWithUsage();
+ Assert.AreEqual(1L, before.Single(entry => entry.Tag.Name == "bug").UsageCount);
+ Assert.AreEqual(1L, before.Single(entry => entry.Tag.Name == "ui").UsageCount);
+
+ // Detaching one tag via an edit confirmation drops its usage to zero but keeps the tag.
+ fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.UiTagId]);
+
+ IReadOnlyList<(TagModel Tag, long UsageCount)> after = fixture.Repository.GetTagsWithUsage();
+ Assert.AreEqual(0L, after.Single(entry => entry.Tag.Name == "bug").UsageCount);
+ Assert.AreEqual(1L, after.Single(entry => entry.Tag.Name == "ui").UsageCount);
+ }
+}
diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs
new file mode 100644
index 0000000..7bade97
--- /dev/null
+++ b/YKanBan.Tests/Storage/Workspace/WorkspaceSessionTests.cs
@@ -0,0 +1,60 @@
+using YKanBan.Models;
+using YKanBan.Storage;
+using YKanBan.Storage.Workspace;
+using YKanBan.Tests.TestUtilities;
+
+namespace YKanBan.Tests.Storage.Workspace;
+
+///
+/// Tests for the workspace session lifecycle: the exclusive lock is held for
+/// the whole session and the repository is usable through it.
+///
+[TestClass]
+public class WorkspaceSessionTests
+{
+ [TestMethod]
+ public void OpenHoldsLockUntilDisposed()
+ {
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+
+ using WorkspaceSession session = WorkspaceSession.Open(directory.FullPath);
+
+ // A second open while the session is alive must fail on the lock.
+ Assert.ThrowsExactly(() => WorkspaceSession.Open(directory.FullPath));
+ }
+
+ [TestMethod]
+ public void DisposeReleasesLockForReopen()
+ {
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+
+ WorkspaceSession.Open(directory.FullPath).Dispose();
+
+ using WorkspaceSession reopened = WorkspaceSession.Open(directory.FullPath);
+ Assert.AreEqual(directory.FullPath, reopened.FolderPath);
+ }
+
+ [TestMethod]
+ public void OpenUninitializedFolderThrowsNotInitialized()
+ {
+ using var directory = new TempDirectory();
+
+ Assert.ThrowsExactly(
+ () => WorkspaceSession.Open(directory.FullPath));
+ }
+
+ [TestMethod]
+ public void RepositoryIsUsableThroughSession()
+ {
+ using var directory = new TempDirectory();
+ WorkspaceInitializer.Initialize(directory.FullPath);
+
+ using WorkspaceSession session = WorkspaceSession.Open(directory.FullPath);
+ TagModel tag = session.Repository.AddTag("bug", RgbColor.Parse("#112233"), "");
+
+ Assert.AreEqual("bug", session.Repository.GetTags().Single().Name);
+ Assert.AreEqual(tag.Id, session.Repository.GetTags().Single().Id);
+ }
+}
diff --git a/YKanBan/Models/CardModel.cs b/YKanBan/Models/CardModel.cs
new file mode 100644
index 0000000..d976b6b
--- /dev/null
+++ b/YKanBan/Models/CardModel.cs
@@ -0,0 +1,20 @@
+namespace YKanBan.Models;
+
+///
+/// A card with its assigned tags loaded.
+///
+/// Row id.
+/// Owning column.
+/// Possibly empty; the empty string is the canonical "no title".
+/// Multiline body, possibly empty.
+/// Unix seconds of creation.
+/// Unix seconds of the last confirmed edit write.
+/// Tags assigned to this card, ordered by tag id.
+public sealed record CardModel(
+ long Id,
+ long ColumnId,
+ string Title,
+ string Content,
+ long CreatedAt,
+ long UpdatedAt,
+ IReadOnlyList Tags);
diff --git a/YKanBan/Models/ColumnModel.cs b/YKanBan/Models/ColumnModel.cs
new file mode 100644
index 0000000..ba2fa74
--- /dev/null
+++ b/YKanBan/Models/ColumnModel.cs
@@ -0,0 +1,11 @@
+namespace YKanBan.Models;
+
+///
+/// A board column as stored in the workspace database.
+///
+/// Row id.
+/// Non-empty, table-unique title.
+/// Free-form description, possibly empty.
+/// Unix seconds of creation.
+/// Unix seconds of the last title/description change.
+public sealed record ColumnModel(long Id, string Title, string Description, long CreatedAt, long UpdatedAt);
diff --git a/YKanBan/Models/RgbColor.cs b/YKanBan/Models/RgbColor.cs
new file mode 100644
index 0000000..b070fc3
--- /dev/null
+++ b/YKanBan/Models/RgbColor.cs
@@ -0,0 +1,87 @@
+namespace YKanBan.Models;
+
+///
+/// An RGB color without an alpha channel, stored as one byte per component —
+/// mirroring the color structures of WinForms, WPF and Avalonia minus the
+/// alpha byte. The type is total: every combination of components is valid,
+/// and all validation happens at the text boundary.
+///
+/// It converts to and from the persisted "#RRGGBB" text form;
+/// always produces the canonical lowercase form (for example "#ff0a00"). It is
+/// not specific to tags and may be reused anywhere a color value is needed.
+///
+public readonly record struct RgbColor(byte R, byte G, byte B)
+{
+ ///
+ /// Parses a "#RRGGBB" text with case-insensitive hex digits.
+ ///
+ /// The text to parse.
+ /// The parsed color.
+ /// The text is not in #RRGGBB form.
+ public static RgbColor Parse(string text) =>
+ TryParse(text, out RgbColor color)
+ ? color
+ : throw new FormatException($"Not a valid #RRGGBB color: '{text}'.");
+
+ ///
+ /// Attempts to parse a "#RRGGBB" text. There is no whitespace tolerance.
+ ///
+ /// The text to parse.
+ /// The parsed color on success, otherwise the default value.
+ /// when the text is exactly #RRGGBB.
+ public static bool TryParse(string? text, out RgbColor color)
+ {
+ color = default;
+ if (text is not { Length: 7 } || text[0] != '#')
+ {
+ return false;
+ }
+
+ // Two hex digits per component: R, G, B.
+ if (TryHexPair(text, 1, out byte r) && TryHexPair(text, 3, out byte g) && TryHexPair(text, 5, out byte b))
+ {
+ color = new RgbColor(r, g, b);
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Returns the canonical lowercase "#RRGGBB" form used by the database and exports.
+ ///
+ /// The lowercase #RRGGBB text.
+ public override string ToString() => $"#{R:x2}{G:x2}{B:x2}";
+
+ ///
+ /// Parses exactly two hex digits into a byte value.
+ ///
+ /// The source text.
+ /// Index of the first of the two digits.
+ /// The parsed byte value.
+ /// when both characters are hex digits.
+ private static bool TryHexPair(string text, int start, out byte value)
+ {
+ value = 0;
+ int high = HexDigit(text[start]);
+ int low = HexDigit(text[start + 1]);
+ if (high < 0 || low < 0)
+ {
+ return false;
+ }
+ value = (byte)((high << 4) | low);
+ return true;
+ }
+
+ ///
+ /// Converts one hex digit to its value.
+ ///
+ /// The character to convert.
+ /// The digit value, or -1 for any other character.
+ private static int HexDigit(char character) => character switch
+ {
+ >= '0' and <= '9' => character - '0',
+ >= 'a' and <= 'f' => character - 'a' + 10,
+ >= 'A' and <= 'F' => character - 'A' + 10,
+ _ => -1,
+ };
+}
diff --git a/YKanBan/Models/TagModel.cs b/YKanBan/Models/TagModel.cs
new file mode 100644
index 0000000..d86a3d8
--- /dev/null
+++ b/YKanBan/Models/TagModel.cs
@@ -0,0 +1,11 @@
+namespace YKanBan.Models;
+
+///
+/// A tag assignable to cards. Tags are never garbage-collected: unreferenced
+/// tags stay until they are deleted explicitly.
+///
+/// Row id.
+/// Arbitrary non-empty text; no charset or length rules.
+/// The tag color; persisted as #RRGGBB text.
+/// Free-form description, possibly empty.
+public sealed record TagModel(long Id, string Name, RgbColor Color, string Description);
diff --git a/YKanBan/Storage/Workspace/WorkspaceRepository.cs b/YKanBan/Storage/Workspace/WorkspaceRepository.cs
new file mode 100644
index 0000000..479296c
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceRepository.cs
@@ -0,0 +1,534 @@
+using Microsoft.Data.Sqlite;
+using YKanBan.Models;
+
+namespace YKanBan.Storage.Workspace;
+
+///
+/// Data access for one open workspace database: CRUD for columns, cards and
+/// tags, card moves and tag assignment. Cascading deletes are left to the DDL.
+///
+/// Timestamp rules:
+/// - cards.updated_at refreshes only when a card edit is confirmed with
+/// changes; moving a card to another column never refreshes it;
+/// - columns.updated_at refreshes only when title or description changes;
+/// - a confirmation with no effective change performs no write at all.
+///
+public sealed class WorkspaceRepository : IDisposable
+{
+ private readonly SqliteConnection _connection;
+
+ ///
+ /// Opens the repository over an initialized workspace database.
+ ///
+ /// The workspace folder.
+ public WorkspaceRepository(string folderPath)
+ {
+ _connection = SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
+ }
+
+ ///
+ /// Closes the underlying connection.
+ ///
+ public void Dispose() => _connection.Dispose();
+
+ #region Columns
+
+ ///
+ /// Returns all columns ordered by id ascending.
+ ///
+ /// All columns in id order.
+ public IReadOnlyList GetColumns()
+ {
+ using var command = CreateCommand(
+ "SELECT id, title, description, created_at, updated_at FROM columns ORDER BY id ASC;");
+ using var reader = command.ExecuteReader();
+
+ var columns = new List();
+ while (reader.Read())
+ {
+ columns.Add(ReadColumn(reader));
+ }
+ return columns;
+ }
+
+ ///
+ /// Returns the column with the given id.
+ ///
+ /// The column id.
+ /// The column, or when it does not exist.
+ public ColumnModel? GetColumn(long columnId)
+ {
+ using var command = CreateCommand(
+ "SELECT id, title, description, created_at, updated_at FROM columns WHERE id = $id;");
+ command.Parameters.AddWithValue("$id", columnId);
+ using var reader = command.ExecuteReader();
+ return reader.Read() ? ReadColumn(reader) : null;
+ }
+
+ ///
+ /// Creates a column with created_at equal to updated_at.
+ ///
+ /// The non-empty, table-unique title.
+ /// The optional description.
+ /// The created column.
+ /// The title is empty or not unique.
+ public ColumnModel AddColumn(string title, string description)
+ {
+ long now = UnixTime.Now;
+ using var transaction = _connection.BeginTransaction();
+ long id = ExecuteInsert(transaction,
+ "INSERT INTO columns (title, description, created_at, updated_at) VALUES ($title, $description, $now, $now);",
+ ("$title", title), ("$description", description), ("$now", now));
+ transaction.Commit();
+
+ return new ColumnModel(id, title, description, now, now);
+ }
+
+ ///
+ /// Rewrites the title and description. A call with no effective change
+ /// writes nothing and leaves the timestamp alone.
+ ///
+ /// The column to update.
+ /// The new non-empty, table-unique title.
+ /// The new description.
+ /// The updated column, or the unchanged one for a no-op.
+ /// The column does not exist.
+ /// The new title is empty or not unique.
+ public ColumnModel UpdateColumn(long columnId, string title, string description)
+ {
+ ColumnModel existing = GetColumn(columnId)
+ ?? throw new InvalidOperationException($"Column {columnId} does not exist.");
+
+ // Unchanged title and description: no write, no timestamp refresh.
+ if (existing.Title == title && existing.Description == description)
+ {
+ return existing;
+ }
+
+ long now = UnixTime.Now;
+ using var command = CreateCommand(
+ "UPDATE columns SET title = $title, description = $description, updated_at = $now WHERE id = $id;");
+ command.Parameters.AddWithValue("$title", title);
+ command.Parameters.AddWithValue("$description", description);
+ command.Parameters.AddWithValue("$now", now);
+ command.Parameters.AddWithValue("$id", columnId);
+ command.ExecuteNonQuery();
+
+ return new ColumnModel(columnId, title, description, existing.CreatedAt, now);
+ }
+
+ ///
+ /// Deletes a column; the DDL cascade removes its cards and their tag links.
+ ///
+ /// The column to delete.
+ /// when a row was deleted.
+ public bool DeleteColumn(long columnId) => ExecuteDelete("DELETE FROM columns WHERE id = $id;", columnId);
+
+ #endregion
+
+ #region Cards
+
+ ///
+ /// Returns all cards with their assigned tags loaded, ordered by id ascending.
+ ///
+ /// All cards in id order.
+ public IReadOnlyList GetCards()
+ {
+ using var command = CreateCommand("""
+ SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at,
+ t.id, t.name, t.color, t.description
+ FROM cards c
+ LEFT JOIN card_tags ct ON ct.card_id = c.id
+ LEFT JOIN tags t ON t.id = ct.tag_id
+ ORDER BY c.id ASC;
+ """);
+ using var reader = command.ExecuteReader();
+
+ // Single grouped pass: one output card per distinct card id, with its tags accumulated alongside.
+ var cards = new List();
+ var tagsByCard = new Dictionary>();
+ while (reader.Read())
+ {
+ long cardId = reader.GetInt64(0);
+ if (!tagsByCard.TryGetValue(cardId, out List? tags))
+ {
+ tags = new List();
+ tagsByCard[cardId] = tags;
+ cards.Add(new CardModel(
+ cardId,
+ reader.GetInt64(1),
+ reader.GetString(2),
+ reader.GetString(3),
+ reader.GetInt64(4),
+ reader.GetInt64(5),
+ tags));
+ }
+
+ if (!reader.IsDBNull(6))
+ {
+ tags.Add(ReadTag(reader, 6));
+ }
+ }
+ return cards;
+ }
+
+ ///
+ /// Returns the card with the given id, with its tags loaded.
+ ///
+ /// The card id.
+ /// The card, or when it does not exist.
+ public CardModel? GetCard(long cardId)
+ {
+ using var command = CreateCommand("""
+ SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at,
+ t.id, t.name, t.color, t.description
+ FROM cards c
+ LEFT JOIN card_tags ct ON ct.card_id = c.id
+ LEFT JOIN tags t ON t.id = ct.tag_id
+ WHERE c.id = $id
+ ORDER BY c.id ASC;
+ """);
+ command.Parameters.AddWithValue("$id", cardId);
+ using var reader = command.ExecuteReader();
+
+ // The LEFT JOIN may repeat the card row once per tag; build it on the first row only.
+ CardModel? card = null;
+ var tags = new List();
+ while (reader.Read())
+ {
+ card ??= new CardModel(
+ reader.GetInt64(0),
+ reader.GetInt64(1),
+ reader.GetString(2),
+ reader.GetString(3),
+ reader.GetInt64(4),
+ reader.GetInt64(5),
+ tags);
+
+ if (!reader.IsDBNull(6))
+ {
+ tags.Add(ReadTag(reader, 6));
+ }
+ }
+ return card;
+ }
+
+ ///
+ /// Creates a card with its initial tag assignment and created_at equal to updated_at.
+ ///
+ /// The owning column.
+ /// The card title, possibly empty.
+ /// The card content, possibly empty.
+ /// The tags to assign initially.
+ /// The created card with tags loaded.
+ /// The column or a tag id does not exist.
+ public CardModel AddCard(long columnId, string title, string content, IReadOnlyCollection tagIds)
+ {
+ long now = UnixTime.Now;
+ using var transaction = _connection.BeginTransaction();
+ long id = ExecuteInsert(transaction,
+ "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES ($columnId, $title, $content, $now, $now);",
+ ("$columnId", columnId), ("$title", title), ("$content", content), ("$now", now));
+ foreach (long tagId in tagIds.Distinct())
+ {
+ ExecuteNonQuery(transaction,
+ "INSERT INTO card_tags (card_id, tag_id) VALUES ($cardId, $tagId);",
+ ("$cardId", id), ("$tagId", tagId));
+ }
+ transaction.Commit();
+
+ return new CardModel(id, columnId, title, content, now, now, LoadTags(id));
+ }
+
+ ///
+ /// Applies one card edit confirmation as a single atomic unit: the card row
+ /// (title, content, refreshed updated_at) plus the complete tag assignment.
+ /// A confirmation with no effective change writes nothing; a failure
+ /// mid-way rolls the whole edit back.
+ ///
+ /// The card to update.
+ /// The new title.
+ /// The new content.
+ /// The complete set of tags to assign.
+ /// The updated card with tags loaded, or the unchanged one for a no-op.
+ /// The card does not exist.
+ /// A tag id does not exist; nothing is written.
+ public CardModel UpdateCard(long cardId, string title, string content, IReadOnlyCollection tagIds)
+ {
+ CardModel existing = GetCard(cardId)
+ ?? throw new InvalidOperationException($"Card {cardId} does not exist.");
+
+ // No effective change in title, content or tag set: no write, no timestamp refresh.
+ bool tagsChanged = !tagIds.Distinct().ToHashSet().SetEquals(existing.Tags.Select(tag => tag.Id));
+ if (existing.Title == title && existing.Content == content && !tagsChanged)
+ {
+ return existing;
+ }
+
+ // The confirmed write refreshes updated_at even when only the tag set changed.
+ long now = UnixTime.Now;
+ using var transaction = _connection.BeginTransaction();
+ ExecuteNonQuery(transaction,
+ "UPDATE cards SET title = $title, content = $content, updated_at = $now WHERE id = $id;",
+ ("$title", title), ("$content", content), ("$now", now), ("$id", cardId));
+
+ // Tag assignment is rewritten wholesale; card_tags carries nothing beyond the pair.
+ ExecuteNonQuery(transaction, "DELETE FROM card_tags WHERE card_id = $id;", ("$id", cardId));
+ foreach (long tagId in tagIds.Distinct())
+ {
+ ExecuteNonQuery(transaction,
+ "INSERT INTO card_tags (card_id, tag_id) VALUES ($cardId, $tagId);",
+ ("$cardId", cardId), ("$tagId", tagId));
+ }
+ transaction.Commit();
+
+ return new CardModel(cardId, existing.ColumnId, title, content, existing.CreatedAt, now, LoadTags(cardId));
+ }
+
+ ///
+ /// Moves a card to another column. This writes to the database but
+ /// deliberately does NOT refresh the card's updated_at; moving to the
+ /// current column is a no-op.
+ ///
+ /// The card to move.
+ /// The destination column.
+ /// when the card actually moved.
+ public bool MoveCard(long cardId, long targetColumnId)
+ {
+ using var command = CreateCommand(
+ "UPDATE cards SET column_id = $columnId WHERE id = $id AND column_id <> $columnId;");
+ command.Parameters.AddWithValue("$columnId", targetColumnId);
+ command.Parameters.AddWithValue("$id", cardId);
+ return command.ExecuteNonQuery() > 0;
+ }
+
+ ///
+ /// Deletes a card; the DDL cascade removes its tag links.
+ ///
+ /// The card to delete.
+ /// when a row was deleted.
+ public bool DeleteCard(long cardId) => ExecuteDelete("DELETE FROM cards WHERE id = $id;", cardId);
+
+ #endregion
+
+ #region Tags
+
+ ///
+ /// Returns all tags ordered by id ascending; unreferenced tags are included.
+ ///
+ /// All tags in id order.
+ public IReadOnlyList GetTags()
+ {
+ using var command = CreateCommand(
+ "SELECT id, name, color, description FROM tags ORDER BY id ASC;");
+ using var reader = command.ExecuteReader();
+
+ var tags = new List();
+ while (reader.Read())
+ {
+ tags.Add(ReadTag(reader, 0));
+ }
+ return tags;
+ }
+
+ ///
+ /// Returns every tag together with the number of cards using it, ordered by tag id.
+ ///
+ /// The tag/usage pairs in tag id order.
+ public IReadOnlyList<(TagModel Tag, long UsageCount)> GetTagsWithUsage()
+ {
+ using var command = CreateCommand("""
+ SELECT t.id, t.name, t.color, t.description, COUNT(ct.card_id)
+ FROM tags t
+ LEFT JOIN card_tags ct ON ct.tag_id = t.id
+ GROUP BY t.id, t.name, t.color, t.description
+ ORDER BY t.id ASC;
+ """);
+ using var reader = command.ExecuteReader();
+
+ var tags = new List<(TagModel Tag, long UsageCount)>();
+ while (reader.Read())
+ {
+ tags.Add((ReadTag(reader, 0), reader.GetInt64(4)));
+ }
+ return tags;
+ }
+
+ ///
+ /// Creates a tag.
+ ///
+ /// The non-empty, table-unique name.
+ /// The tag color.
+ /// The optional description.
+ /// The created tag.
+ /// The name is empty or not unique.
+ public TagModel AddTag(string name, RgbColor color, string description)
+ {
+ using var transaction = _connection.BeginTransaction();
+ long id = ExecuteInsert(transaction,
+ "INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);",
+ ("$name", name), ("$color", color.ToString()), ("$description", description));
+ transaction.Commit();
+
+ return new TagModel(id, name, color, description);
+ }
+
+ ///
+ /// Rewrites name, color and description. A call with no effective change writes nothing.
+ ///
+ /// The tag to update.
+ /// The new non-empty, table-unique name.
+ /// The new color.
+ /// The new description.
+ /// The updated tag, or the unchanged one for a no-op.
+ /// The tag does not exist.
+ /// The new name is empty or not unique.
+ public TagModel UpdateTag(long tagId, string name, RgbColor color, string description)
+ {
+ TagModel existing = GetTag(tagId)
+ ?? throw new InvalidOperationException($"Tag {tagId} does not exist.");
+
+ // Unchanged name, color and description: no write.
+ if (existing.Name == name && existing.Color == color && existing.Description == description)
+ {
+ return existing;
+ }
+
+ using var command = CreateCommand(
+ "UPDATE tags SET name = $name, color = $color, description = $description WHERE id = $id;");
+ command.Parameters.AddWithValue("$name", name);
+ command.Parameters.AddWithValue("$color", color.ToString());
+ command.Parameters.AddWithValue("$description", description);
+ command.Parameters.AddWithValue("$id", tagId);
+ command.ExecuteNonQuery();
+
+ return new TagModel(tagId, name, color, description);
+ }
+
+ ///
+ /// Deletes a tag; the DDL cascade removes its card links while the cards survive.
+ ///
+ /// The tag to delete.
+ /// when a row was deleted.
+ public bool DeleteTag(long tagId) => ExecuteDelete("DELETE FROM tags WHERE id = $id;", tagId);
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// Returns the tag with the given id.
+ ///
+ /// The tag id.
+ /// The tag, or when it does not exist.
+ private TagModel? GetTag(long tagId)
+ {
+ using var command = CreateCommand(
+ "SELECT id, name, color, description FROM tags WHERE id = $id;");
+ command.Parameters.AddWithValue("$id", tagId);
+ using var reader = command.ExecuteReader();
+ return reader.Read() ? ReadTag(reader, 0) : null;
+ }
+
+ ///
+ /// Returns the tags currently assigned to a card, ordered by tag id.
+ ///
+ /// The card id.
+ /// The assigned tags in id order.
+ private IReadOnlyList LoadTags(long cardId)
+ {
+ using var command = CreateCommand("""
+ SELECT t.id, t.name, t.color, t.description
+ FROM tags t
+ JOIN card_tags ct ON ct.tag_id = t.id
+ WHERE ct.card_id = $cardId
+ ORDER BY t.id ASC;
+ """);
+ command.Parameters.AddWithValue("$cardId", cardId);
+ using var reader = command.ExecuteReader();
+
+ var tags = new List();
+ while (reader.Read())
+ {
+ tags.Add(ReadTag(reader, 0));
+ }
+ return tags;
+ }
+
+ ///
+ /// Reads a columns row from the reader cursor.
+ ///
+ /// The reader positioned on a columns row.
+ /// The column model.
+ private static ColumnModel ReadColumn(SqliteDataReader reader) =>
+ new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetInt64(3), reader.GetInt64(4));
+
+ ///
+ /// Reads a tags row from the reader cursor, starting at the given column
+ /// offset; the color text is converted to its components.
+ ///
+ /// The reader positioned on a row containing a tag.
+ /// Index of the tag's id column.
+ /// The tag model.
+ private static TagModel ReadTag(SqliteDataReader reader, int offset) =>
+ new(reader.GetInt64(offset), reader.GetString(offset + 1), RgbColor.Parse(reader.GetString(offset + 2)), reader.GetString(offset + 3));
+
+ ///
+ /// Creates a command bound to this connection.
+ ///
+ /// The command text.
+ /// The transaction to enlist in, when any.
+ /// The prepared command.
+ private SqliteCommand CreateCommand(string sql, SqliteTransaction? transaction = null)
+ {
+ SqliteCommand command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = sql;
+ return command;
+ }
+
+ ///
+ /// Executes a parameterized non-query statement inside a transaction.
+ ///
+ /// The transaction to enlist in.
+ /// The statement to execute.
+ /// Name/value pairs bound as SQL parameters.
+ private void ExecuteNonQuery(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters)
+ {
+ using SqliteCommand command = CreateCommand(sql, transaction);
+ foreach ((string name, object? value) in parameters)
+ {
+ command.Parameters.AddWithValue(name, value);
+ }
+ command.ExecuteNonQuery();
+ }
+
+ ///
+ /// Inserts a row inside a transaction and returns the generated rowid.
+ ///
+ /// The transaction to enlist in.
+ /// The INSERT statement.
+ /// Name/value pairs bound as SQL parameters.
+ /// The rowid assigned to the inserted row.
+ private long ExecuteInsert(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters)
+ {
+ ExecuteNonQuery(transaction, sql, parameters);
+ using SqliteCommand rowId = CreateCommand("SELECT last_insert_rowid();", transaction);
+ return (long)rowId.ExecuteScalar()!;
+ }
+
+ ///
+ /// Deletes a row by id.
+ ///
+ /// The DELETE statement with an $id parameter.
+ /// The id to delete.
+ /// when a row was deleted.
+ private bool ExecuteDelete(string sql, long id)
+ {
+ using SqliteCommand command = CreateCommand(sql);
+ command.Parameters.AddWithValue("$id", id);
+ return command.ExecuteNonQuery() > 0;
+ }
+
+ #endregion
+}
diff --git a/YKanBan/Storage/Workspace/WorkspaceSession.cs b/YKanBan/Storage/Workspace/WorkspaceSession.cs
new file mode 100644
index 0000000..4d11333
--- /dev/null
+++ b/YKanBan/Storage/Workspace/WorkspaceSession.cs
@@ -0,0 +1,67 @@
+namespace YKanBan.Storage.Workspace;
+
+///
+/// One open workspace for the process lifetime: the exclusive lock plus the
+/// single repository connection. Opening acquires the lock first, so a folder
+/// without .ykanban, or one already locked by another instance, fails before
+/// any database work happens. Disposing releases the repository and then the
+/// lock.
+///
+public sealed class WorkspaceSession : IDisposable
+{
+ private readonly WorkspaceLock _workspaceLock;
+
+ ///
+ /// Initializes the session around an acquired lock and an open repository.
+ ///
+ /// The canonical workspace folder path.
+ /// The exclusive lock held while this session is open.
+ /// The data access object for this workspace.
+ private WorkspaceSession(string folderPath, WorkspaceLock workspaceLock, WorkspaceRepository repository)
+ {
+ FolderPath = folderPath;
+ _workspaceLock = workspaceLock;
+ Repository = repository;
+ }
+
+ ///
+ /// Gets the canonical workspace folder path.
+ ///
+ public string FolderPath { get; }
+
+ ///
+ /// Gets the data access object for this workspace.
+ ///
+ public WorkspaceRepository Repository { get; }
+
+ ///
+ /// Opens a workspace: acquires its exclusive lock and opens its repository.
+ ///
+ /// The workspace folder to open.
+ /// The opened session; dispose it to release the lock and connection.
+ /// The folder has no .ykanban structure.
+ /// Another instance already holds the lock.
+ public static WorkspaceSession Open(string folderPath)
+ {
+ WorkspaceLock workspaceLock = WorkspaceLock.Acquire(folderPath);
+ try
+ {
+ var repository = new WorkspaceRepository(folderPath);
+ return new WorkspaceSession(folderPath, workspaceLock, repository);
+ }
+ catch
+ {
+ workspaceLock.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Releases the repository connection, then the exclusive lock.
+ ///
+ public void Dispose()
+ {
+ Repository.Dispose();
+ _workspaceLock.Dispose();
+ }
+}