feat: add storage foundation and i18n resources

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

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
English (neutral) resources. Any key missing from a satellite culture falls back to here.
This file is processed by the cross-platform MSBuild strongly-typed resource generator; see YKanBan.csproj.
-->
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="PresetColumn_Todo" xml:space="preserve">
<value>To Do</value>
</data>
<data name="PresetColumn_InProgress" xml:space="preserve">
<value>In Progress</value>
</data>
<data name="PresetColumn_Done" xml:space="preserve">
<value>Done</value>
</data>
</root>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Simplified Chinese (zh-Hans) satellite resources.
This file is embedded beside the neutral resources; see YKanBan.csproj.
-->
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="PresetColumn_Todo" xml:space="preserve">
<value>待办</value>
</data>
<data name="PresetColumn_InProgress" xml:space="preserve">
<value>进行中</value>
</data>
<data name="PresetColumn_Done" xml:space="preserve">
<value>已完成</value>
</data>
</root>
+31
View File
@@ -0,0 +1,31 @@
using System.Diagnostics.CodeAnalysis;
namespace YKanBan.Launching;
/// <summary>
/// Parses raw command-line arguments. The only accepted form is a single,
/// non-blank workspace path (<c>ykanban &lt;path&gt;</c>); zero arguments,
/// more than one argument, or a blank argument is rejected as an argument
/// error. The path itself is not checked for existence here.
/// </summary>
public static class LaunchArguments
{
/// <summary>
/// Tries to parse command-line arguments following the
/// <c>ykanban &lt;path&gt;</c> contract.
/// </summary>
/// <param name="arguments">The raw arguments, excluding the executable name.</param>
/// <param name="folderPath">The parsed workspace path on success, otherwise <see langword="null"/>.</param>
/// <returns><see langword="true"/> when exactly one non-blank path was supplied.</returns>
public static bool TryParse(IReadOnlyList<string> arguments, [NotNullWhen(true)] out string? folderPath)
{
if (arguments.Count == 1 && !string.IsNullOrWhiteSpace(arguments[0]))
{
folderPath = arguments[0];
return true;
}
folderPath = null;
return false;
}
}
+183
View File
@@ -0,0 +1,183 @@
using System.Runtime.Serialization;
using Newtonsoft.Json;
namespace YKanBan.Storage.AppData;
/// <summary>
/// Card ordering options. Every order falls back to id ascending as the final
/// tie-breaker.
/// </summary>
public enum CardSortOption
{
/// <summary>Primary key ascending (default).</summary>
[EnumMember(Value = "id")]
Id,
/// <summary>Creation time, oldest first.</summary>
[EnumMember(Value = "created-at")]
CreatedAt,
/// <summary>Last modification time, most recently modified first.</summary>
[EnumMember(Value = "updated-at")]
UpdatedAt,
/// <summary>Title by code points (BINARY collation); empty titles sort first.</summary>
[EnumMember(Value = "title")]
Title,
}
/// <summary>
/// Column width presets. <see cref="Custom"/> uses a user-defined pixel width.
/// </summary>
public enum ColumnWidthPreset
{
/// <summary>Narrow preset.</summary>
[EnumMember(Value = "narrow")]
Narrow,
/// <summary>Standard preset (320 px, the default).</summary>
[EnumMember(Value = "standard")]
Standard,
/// <summary>Wide preset.</summary>
[EnumMember(Value = "wide")]
Wide,
/// <summary>User-defined pixel width (160–720).</summary>
[EnumMember(Value = "custom")]
Custom,
}
/// <summary>
/// UI theme selection.
/// </summary>
public enum ThemeOption
{
/// <summary>Always light.</summary>
[EnumMember(Value = "light")]
Light,
/// <summary>Always dark.</summary>
[EnumMember(Value = "dark")]
Dark,
/// <summary>Follow the operating system setting (default).</summary>
[EnumMember(Value = "follow-system")]
FollowSystem,
}
/// <summary>
/// Card ordering settings. The chosen order is global, not per workspace.
/// </summary>
public sealed class SortSettings
{
/// <summary>
/// Gets or sets the order used by the board.
/// </summary>
[JsonProperty("card")]
public CardSortOption Card { get; set; } = CardSortOption.Id;
}
/// <summary>
/// Board column width settings, applied globally.
/// </summary>
public sealed class ColumnWidthSettings
{
/// <summary>
/// Gets or sets the active width preset.
/// </summary>
[JsonProperty("preset")]
public ColumnWidthPreset Preset { get; set; } = ColumnWidthPreset.Standard;
/// <summary>
/// Gets or sets the manual width in pixels, used when <see cref="Preset"/>
/// is <see cref="ColumnWidthPreset.Custom"/> (range 160–720).
/// </summary>
[JsonProperty("custom-pixels")]
public int CustomPixels { get; set; } = 320;
}
/// <summary>
/// Confirmation-dialog toggles for the destructive actions.
/// </summary>
public sealed class ConfirmationSettings
{
/// <summary>
/// Gets or sets whether deleting a card asks for confirmation.
/// </summary>
[JsonProperty("delete-card")]
public bool DeleteCard { get; set; } = true;
/// <summary>
/// Gets or sets whether deleting a column asks for confirmation.
/// </summary>
[JsonProperty("delete-column")]
public bool DeleteColumn { get; set; } = true;
/// <summary>
/// Gets or sets whether deleting a tag asks for confirmation.
/// </summary>
[JsonProperty("delete-tag")]
public bool DeleteTag { get; set; } = true;
/// <summary>
/// Gets or sets whether discarding card edits asks for confirmation.
/// </summary>
[JsonProperty("discard-edit")]
public bool DiscardEdit { get; set; } = true;
}
/// <summary>
/// Root object of app.json, the whole-file key/value configuration store.
/// Keys and enum values are serialized in kebab-case (declared explicitly via
/// <see cref="JsonPropertyAttribute"/> and <see cref="EnumMemberAttribute"/>).
/// The <c>version</c> field is the migration hook for future format changes.
/// </summary>
public sealed class AppConfig
{
/// <summary>
/// Current configuration format version.
/// </summary>
public const int CurrentFormatVersion = 1;
/// <summary>
/// Default UI language code.
/// </summary>
public const string DefaultLanguage = "en";
/// <summary>
/// Gets or sets the configuration format version of this file.
/// </summary>
[JsonProperty("version")]
public int Version { get; set; } = CurrentFormatVersion;
/// <summary>
/// Gets or sets the UI language code ("en" default or "zh-Hans").
/// </summary>
[JsonProperty("language")]
public string Language { get; set; } = DefaultLanguage;
/// <summary>
/// Gets or sets the UI theme selection (default: follow the system).
/// </summary>
[JsonProperty("theme")]
public ThemeOption Theme { get; set; } = ThemeOption.FollowSystem;
/// <summary>
/// Gets or sets the card ordering settings.
/// </summary>
[JsonProperty("sort")]
public SortSettings Sort { get; set; } = new();
/// <summary>
/// Gets or sets the board column width settings.
/// </summary>
[JsonProperty("column-width")]
public ColumnWidthSettings ColumnWidth { get; set; } = new();
/// <summary>
/// Gets or sets the confirmation-dialog settings.
/// </summary>
[JsonProperty("confirmations")]
public ConfirmationSettings Confirmations { get; set; } = new();
}
+87
View File
@@ -0,0 +1,87 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace YKanBan.Storage.AppData;
/// <summary>
/// Loads and saves app.json with graceful degradation: a missing file yields
/// defaults; a corrupt file is backed up to app.json.bak and then defaults are
/// used. Saving writes the file directly (no atomic replace) — losing the last
/// session's settings to a crash is an accepted trade-off.
/// </summary>
public sealed class AppConfigStore
{
private static readonly JsonSerializerSettings SerializerSettings = new()
{
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore,
// Enums round-trip through the kebab-case strings declared via [EnumMember].
Converters = { new StringEnumConverter() },
};
/// <summary>
/// Initializes a store bound to a specific app.json path.
/// </summary>
/// <param name="filePath">Full path of the app.json file to manage.</param>
public AppConfigStore(string filePath) => FilePath = filePath;
/// <summary>
/// Gets the full path of the managed app.json file.
/// </summary>
public string FilePath { get; }
/// <summary>
/// Loads the configuration. A missing file yields defaults; a corrupt file
/// is backed up to app.json.bak and then defaults are used.
/// </summary>
/// <returns>The loaded configuration, or defaults when none could be read.</returns>
/// <exception cref="IOException">The file exists but cannot be read.</exception>
public AppConfig Load()
{
// Missing file is a normal first-run state: run with defaults.
if (!File.Exists(FilePath))
{
return new AppConfig();
}
string json = File.ReadAllText(FilePath);
try
{
// A JSON null literal deserializes to null; treat it like defaults.
AppConfig? config = JsonConvert.DeserializeObject<AppConfig>(json, SerializerSettings);
if (config is null)
{
return new AppConfig();
}
// Older or hand-edited files may miss whole sections; each falls back to its defaults.
config.Sort ??= new SortSettings();
config.ColumnWidth ??= new ColumnWidthSettings();
config.Confirmations ??= new ConfirmationSettings();
return config;
}
catch (JsonException)
{
// Corrupt: keep a copy for inspection, then continue with defaults.
File.Copy(FilePath, FilePath + ".bak", overwrite: true);
return new AppConfig();
}
}
/// <summary>
/// Writes the configuration directly to disk (no atomic replace).
/// </summary>
/// <param name="config">The configuration to serialize.</param>
public void Save(AppConfig config)
{
// Create the parent folder on demand; normally it already exists.
string? directory = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(FilePath, JsonConvert.SerializeObject(config, SerializerSettings));
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace YKanBan.Storage.AppData;
/// <summary>
/// Well-known locations under the per-user application data folder
/// (%APPDATA%/YKanBan on Windows and the equivalent directory on other
/// platforms).
/// </summary>
public static class AppDataPaths
{
/// <summary>
/// Gets the per-user YKanBan application data folder.
/// </summary>
public static string Directory =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "YKanBan");
/// <summary>
/// Gets the full path of app.json.
/// </summary>
public static string AppConfigJsonPath => Path.Combine(Directory, "app.json");
}
+11
View File
@@ -0,0 +1,11 @@
namespace YKanBan.Storage;
/// <summary>
/// A single incremental schema step. Migrations form a contiguous list
/// starting at version 1; each step is applied when the stored
/// <c>user_version</c> is exactly one below <see cref="Version"/>.
/// </summary>
/// <param name="Version">Target <c>user_version</c> after this migration is applied.</param>
/// <param name="Name">Short human-readable identifier used for diagnostics.</param>
/// <param name="Sql">SQL script executed atomically together with the version bump.</param>
public sealed record SchemaMigration(int Version, string Name, string Sql);
+126
View File
@@ -0,0 +1,126 @@
using Microsoft.Data.Sqlite;
namespace YKanBan.Storage;
/// <summary>
/// Shared SQLite plumbing for workspace databases: opens a connection with
/// the mandated PRAGMA configuration (WAL journal, NORMAL synchronous mode,
/// foreign keys on) and applies incremental <c>user_version</c> migrations.
/// </summary>
public static class SqliteDatabase
{
/// <summary>
/// Opens (creating if needed) a database file, applies the mandatory
/// PRAGMAs and runs any pending migrations.
/// </summary>
/// <param name="databasePath">Path of the SQLite database file.</param>
/// <param name="migrations">Contiguous migration list ordered by version, starting at 1.</param>
/// <returns>An open connection with all PRAGMAs applied and the schema migrated.</returns>
public static SqliteConnection Open(string databasePath, IReadOnlyList<SchemaMigration> migrations)
{
var connection = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = databasePath,
Mode = SqliteOpenMode.ReadWriteCreate,
// Each process holds its own single connection; a pool would only add noise.
Pooling = false,
}.ToString());
connection.Open();
try
{
ApplyPragmas(connection);
ApplyMigrations(connection, migrations);
return connection;
}
catch
{
connection.Dispose();
throw;
}
}
/// <summary>
/// Reads the stored <c>PRAGMA user_version</c> value.
/// </summary>
/// <param name="connection">An open database connection.</param>
/// <returns>The stored schema version.</returns>
public static long ReadUserVersion(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = "PRAGMA user_version;";
return (long)command.ExecuteScalar()!;
}
/// <summary>
/// Applies the per-connection PRAGMAs. WAL is a persistent database
/// property, but the statement is harmless to repeat; foreign keys must be
/// enabled on every connection or the DDL cascades never fire.
/// </summary>
/// <param name="connection">An open database connection.</param>
internal static void ApplyPragmas(SqliteConnection connection)
{
// WAL plus NORMAL synchronous is the chosen durability/performance trade-off.
Execute(connection, "PRAGMA journal_mode=WAL;");
Execute(connection, "PRAGMA synchronous=NORMAL;");
Execute(connection, "PRAGMA foreign_keys=ON;");
}
/// <summary>
/// Runs every migration newer than the stored <c>user_version</c>, each in
/// its own transaction together with its version bump.
/// </summary>
/// <param name="connection">An open database connection.</param>
/// <param name="migrations">Contiguous migration list ordered by version, starting at 1.</param>
/// <exception cref="ArgumentException">The migration list is not contiguous from version 1.</exception>
/// <exception cref="SchemaVersionException">The database was written by a newer build.</exception>
internal static void ApplyMigrations(SqliteConnection connection, IReadOnlyList<SchemaMigration> migrations)
{
// Contract check: the list must be ordered, contiguous and start at version 1.
for (int index = 0; index < migrations.Count; index++)
{
if (migrations[index].Version != index + 1)
{
throw new ArgumentException("Migrations must be contiguous and start at version 1.", nameof(migrations));
}
}
long current = ReadUserVersion(connection);
int latest = migrations.Count;
// A database written by a newer build cannot be managed by this one; refuse loudly.
if (current > latest)
{
throw new SchemaVersionException(current, latest);
}
// Apply each pending migration atomically so a crash never leaves a half-migrated database.
foreach (SchemaMigration migration in migrations)
{
if (migration.Version <= current)
{
continue;
}
using var transaction = connection.BeginTransaction();
Execute(connection, migration.Sql, transaction);
Execute(connection, $"PRAGMA user_version={migration.Version};", transaction);
transaction.Commit();
}
}
/// <summary>
/// Executes a non-query SQL script, optionally inside an explicit transaction.
/// </summary>
/// <param name="connection">An open database connection.</param>
/// <param name="commandText">The SQL script to execute.</param>
/// <param name="transaction">The transaction to enlist in, when any.</param>
private static void Execute(SqliteConnection connection, string commandText, SqliteTransaction? transaction = null)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = commandText;
command.ExecuteNonQuery();
}
}
+138
View File
@@ -0,0 +1,138 @@
namespace YKanBan.Storage;
/// <summary>
/// Base class for the typed exceptions raised by the storage layer.
/// Exception messages are always English and never localized; the UI maps the
/// exception type to a localized resource key instead.
/// </summary>
public abstract class YKanBanException : Exception
{
/// <summary>
/// Initializes the exception with an English diagnostic message.
/// </summary>
/// <param name="message">The English diagnostic message.</param>
/// <param name="innerException">The underlying cause, when any.</param>
protected YKanBanException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
/// <summary>
/// Signals that the folder passed on the command line does not exist on disk.
/// </summary>
public sealed class WorkspaceDirectoryMissingException : YKanBanException
{
/// <summary>
/// Gets the folder path that was expected to exist.
/// </summary>
public string FolderPath { get; }
/// <summary>
/// Initializes the exception for a folder that is absent on disk.
/// </summary>
/// <param name="folderPath">The missing folder path.</param>
public WorkspaceDirectoryMissingException(string folderPath)
: base($"The folder does not exist: {folderPath}")
{
FolderPath = folderPath;
}
}
/// <summary>
/// Signals that a folder exists but has no .ykanban structure, so it is not
/// (yet) a workspace.
/// </summary>
public sealed class WorkspaceNotInitializedException : YKanBanException
{
/// <summary>
/// Gets the folder path that lacks the .ykanban structure.
/// </summary>
public string FolderPath { get; }
/// <summary>
/// Initializes the exception for a folder without a .ykanban structure.
/// </summary>
/// <param name="folderPath">The uninitialized folder path.</param>
public WorkspaceNotInitializedException(string folderPath)
: base($"The folder is not a YKanBan workspace (missing .ykanban): {folderPath}")
{
FolderPath = folderPath;
}
}
/// <summary>
/// Signals that another instance already holds the workspace lock, so this
/// process must not touch the workspace.
/// </summary>
public sealed class WorkspaceLockException : YKanBanException
{
/// <summary>
/// Gets the lock file that could not be acquired.
/// </summary>
public string LockFilePath { get; }
/// <summary>
/// Gets the best-effort holder diagnostics read from the lock file, or
/// <see langword="null"/> when they could not be read.
/// </summary>
public string? HolderDiagnostics { get; }
/// <summary>
/// Initializes the exception for a failed lock acquisition.
/// </summary>
/// <param name="lockFilePath">The lock file that is already held.</param>
/// <param name="holderDiagnostics">Diagnostics of the current holder, when readable.</param>
/// <param name="innerException">The underlying I/O error.</param>
public WorkspaceLockException(string lockFilePath, string? holderDiagnostics, Exception innerException)
: base(BuildMessage(lockFilePath, holderDiagnostics), innerException)
{
LockFilePath = lockFilePath;
HolderDiagnostics = holderDiagnostics;
}
/// <summary>
/// Composes the diagnostic message, appending holder details when they were readable.
/// </summary>
/// <param name="lockFilePath">The lock file that is already held.</param>
/// <param name="holderDiagnostics">Diagnostics of the current holder, when readable.</param>
/// <returns>The English diagnostic message.</returns>
private static string BuildMessage(string lockFilePath, string? holderDiagnostics)
{
string message = $"The workspace is locked by another instance: {lockFilePath}";
if (!string.IsNullOrEmpty(holderDiagnostics))
{
message += $" Lock holder: {holderDiagnostics}";
}
return message;
}
}
/// <summary>
/// Signals that the database was written by a newer build than the one
/// currently running, so its schema cannot be safely managed.
/// </summary>
public sealed class SchemaVersionException : YKanBanException
{
/// <summary>
/// Gets the user_version value found in the database.
/// </summary>
public long StoredVersion { get; }
/// <summary>
/// Gets the newest schema version this build understands.
/// </summary>
public int SupportedVersion { get; }
/// <summary>
/// Initializes the exception for an unmanageable schema version.
/// </summary>
/// <param name="storedVersion">The user_version read from the database.</param>
/// <param name="supportedVersion">The newest schema version this build supports.</param>
public SchemaVersionException(long storedVersion, int supportedVersion)
: base($"Database schema version {storedVersion} is newer than the latest supported version {supportedVersion}.")
{
StoredVersion = storedVersion;
SupportedVersion = supportedVersion;
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace YKanBan.Storage;
/// <summary>
/// Supplies the current Unix timestamp. Every timestamp persisted by YKanBan
/// is an INTEGER count of Unix seconds.
/// </summary>
public static class UnixTime
{
/// <summary>
/// Gets the current UTC time expressed as Unix seconds.
/// </summary>
public static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
@@ -0,0 +1,45 @@
using Microsoft.Data.Sqlite;
namespace YKanBan.Storage.Workspace;
/// <summary>
/// Creates the .ykanban structure for a folder: the folder itself and an
/// empty, fully migrated database. Adding preset content is deliberately a
/// separate concern handled by <see cref="WorkspacePreset"/>, so the
/// database can be created without any rows.
/// </summary>
public static class WorkspaceInitializer
{
/// <summary>
/// Returns whether the folder contains a .ykanban structure. Only the
/// passed folder is checked; parent folders are never searched.
/// </summary>
/// <param name="folderPath">The folder to inspect.</param>
/// <returns><see langword="true"/> when .ykanban exists in the folder.</returns>
public static bool IsWorkspace(string folderPath) => Directory.Exists(WorkspacePaths.Root(folderPath));
/// <summary>
/// Initializes a fresh workspace: creates .ykanban and an empty migrated
/// database, without adding any preset content.
/// </summary>
/// <param name="folderPath">The existing folder to initialize.</param>
/// <exception cref="WorkspaceDirectoryMissingException">The folder does not exist on disk.</exception>
/// <exception cref="InvalidOperationException">The workspace database already exists.</exception>
public static void Initialize(string folderPath)
{
if (!Directory.Exists(folderPath))
{
throw new WorkspaceDirectoryMissingException(folderPath);
}
string databasePath = WorkspacePaths.Database(folderPath);
if (File.Exists(databasePath))
{
throw new InvalidOperationException($"The workspace database already exists: {databasePath}");
}
// Create the .ykanban folder, then the migrated but still empty database.
Directory.CreateDirectory(WorkspacePaths.Root(folderPath));
using SqliteConnection connection = SqliteDatabase.Open(databasePath, WorkspaceSchema.Migrations);
}
}
@@ -0,0 +1,91 @@
using System.Text;
namespace YKanBan.Storage.Workspace;
/// <summary>
/// Exclusive, OS-enforced lock over a workspace's ykanban.lock. The handle is
/// held until disposal, so crashes and power loss release it automatically.
/// The lock file also carries PID / machine name / time for human diagnostics.
/// </summary>
public sealed class WorkspaceLock : IDisposable
{
private readonly FileStream _stream;
/// <summary>
/// Initializes the lock around an already-acquired handle.
/// </summary>
/// <param name="stream">The exclusively opened lock file stream.</param>
/// <param name="lockFilePath">Path of the lock file being held.</param>
private WorkspaceLock(FileStream stream, string lockFilePath)
{
_stream = stream;
LockFilePath = lockFilePath;
}
/// <summary>
/// Gets the path of the lock file being held.
/// </summary>
public string LockFilePath { get; }
/// <summary>
/// Acquires the workspace lock, writing this process's diagnostics into it.
/// </summary>
/// <param name="folderPath">The workspace folder to lock.</param>
/// <returns>The held lock; dispose it to release.</returns>
/// <exception cref="WorkspaceNotInitializedException">The folder has no .ykanban structure.</exception>
/// <exception cref="WorkspaceLockException">Another instance already holds the lock.</exception>
public static WorkspaceLock Acquire(string folderPath)
{
string lockFilePath = WorkspacePaths.LockFile(folderPath);
FileStream stream;
try
{
// FileShare.None gives the exclusive semantics; FileMode.Create truncates stale content.
stream = new FileStream(lockFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
}
catch (DirectoryNotFoundException)
{
throw new WorkspaceNotInitializedException(folderPath);
}
catch (IOException ex)
{
throw new WorkspaceLockException(lockFilePath, ReadDiagnosticsBestEffort(lockFilePath), ex);
}
// Write holder diagnostics so humans can identify the owning instance from the file alone.
string diagnostics = $"pid={Environment.ProcessId};machine={Environment.MachineName};time={DateTimeOffset.UtcNow:O}";
byte[] payload = Encoding.UTF8.GetBytes(diagnostics);
stream.Write(payload, 0, payload.Length);
stream.Flush();
return new WorkspaceLock(stream, lockFilePath);
}
/// <summary>
/// Attempts to read holder diagnostics from a lock file we failed to
/// acquire. The read is best-effort: a live FileShare.None holder makes the
/// file unreadable, in which case <see langword="null"/> is returned.
/// </summary>
/// <param name="lockFilePath">The lock file to read.</param>
/// <returns>The diagnostics text, or <see langword="null"/> when unreadable.</returns>
private static string? ReadDiagnosticsBestEffort(string lockFilePath)
{
try
{
return File.ReadAllText(lockFilePath);
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
/// <summary>
/// Releases the exclusive handle. The lock file itself is intentionally
/// left on disk; the OS releases the handle even on a crash.
/// </summary>
public void Dispose() => _stream.Dispose();
}
@@ -0,0 +1,44 @@
namespace YKanBan.Storage.Workspace;
/// <summary>
/// Well-known file and folder names inside a workspace's .ykanban structure,
/// plus helpers that combine them with a workspace folder path.
/// </summary>
public static class WorkspacePaths
{
/// <summary>
/// Name of the workspace root folder created inside a managed project.
/// </summary>
public const string RootFolderName = ".ykanban";
/// <summary>
/// Name of the SQLite database inside .ykanban.
/// </summary>
public const string DatabaseFileName = "ykanban.db";
/// <summary>
/// Name of the exclusive lock file inside .ykanban.
/// </summary>
public const string LockFileName = "ykanban.lock";
/// <summary>
/// Returns the .ykanban root path for a workspace folder.
/// </summary>
/// <param name="folderPath">The workspace folder path.</param>
/// <returns>The path of the .ykanban folder.</returns>
public static string Root(string folderPath) => Path.Combine(folderPath, RootFolderName);
/// <summary>
/// Returns the workspace database path for a workspace folder.
/// </summary>
/// <param name="folderPath">The workspace folder path.</param>
/// <returns>The path of ykanban.db.</returns>
public static string Database(string folderPath) => Path.Combine(Root(folderPath), DatabaseFileName);
/// <summary>
/// Returns the workspace lock file path for a workspace folder.
/// </summary>
/// <param name="folderPath">The workspace folder path.</param>
/// <returns>The path of ykanban.lock.</returns>
public static string LockFile(string folderPath) => Path.Combine(Root(folderPath), LockFileName);
}
@@ -0,0 +1,50 @@
using Microsoft.Data.Sqlite;
namespace YKanBan.Storage.Workspace;
/// <summary>
/// Adds the initial workspace content. The three preset column titles are read
/// straight from the ResX resources in the UI language active when they are
/// added; they then become ordinary data that no longer follows language
/// switches.
/// </summary>
public static class WorkspacePreset
{
/// <summary>
/// Inserts the three preset columns (the current language's equivalents of
/// To Do / In Progress / Done) in a single transaction.
/// </summary>
/// <param name="folderPath">The initialized workspace folder.</param>
/// <exception cref="SqliteException">The workspace database does not exist or a title collides with an existing column.</exception>
public static void AddPresetColumns(string folderPath)
{
using SqliteConnection connection = SqliteDatabase.Open(
WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations);
long now = UnixTime.Now;
// Preset titles are data at creation time: whatever the current language says gets stored.
string[] titles =
[
Resources.PresetColumn_Todo,
Resources.PresetColumn_InProgress,
Resources.PresetColumn_Done,
];
// Insert all three atomically so a partially populated workspace can never exist.
using var transaction = connection.BeginTransaction();
foreach (string title in titles)
{
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO columns (title, description, created_at, updated_at)
VALUES ($title, '', $now, $now);
""";
command.Parameters.AddWithValue("$title", title);
command.Parameters.AddWithValue("$now", now);
command.ExecuteNonQuery();
}
transaction.Commit();
}
}
@@ -0,0 +1,54 @@
namespace YKanBan.Storage.Workspace;
/// <summary>
/// Schema definitions for the per-workspace database. A workspace holds a
/// single board with a columns → cards hierarchy plus tags and their
/// assignments. Data validity is expressed in DDL (CHECK / UNIQUE / FOREIGN
/// KEY) so every stored row is valid by construction, and cascading deletes
/// are covered by the same DDL.
/// </summary>
public static class WorkspaceSchema
{
/// <summary>
/// Newest workspace schema version this build understands.
/// </summary>
public const int CurrentVersion = 1;
/// <summary>
/// Ordered, contiguous migration list for the workspace database.
/// </summary>
public static readonly IReadOnlyList<SchemaMigration> Migrations =
[
new SchemaMigration(CurrentVersion, "initial schema", """
CREATE TABLE columns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL UNIQUE CHECK (length(title) > 0),
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
column_id INTEGER NOT NULL REFERENCES columns (id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE CHECK (length(name) > 0),
color TEXT NOT NULL CHECK (color GLOB '#[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]'),
description TEXT NOT NULL DEFAULT ''
);
CREATE TABLE card_tags (
card_id INTEGER NOT NULL REFERENCES cards (id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
PRIMARY KEY (card_id, tag_id)
);
"""),
];
}
+24 -1
View File
@@ -2,6 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
@@ -9,7 +10,29 @@
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<!-- The localization resources are embedded via the ResX item group below, not as Avalonia resources. -->
<AvaloniaResource Include="Assets\**" Exclude="Assets\Locales\**" />
</ItemGroup>
<!--
Strongly-typed ResX access is generated with the cross-platform MSBuild generator instead of
the Visual Studio designer. The files physically live in Assets/Locales, but LogicalName is
pinned to the project-root-equivalent manifest names so the generated ResourceManager and
its satellites keep stable, location-independent names.
-->
<ItemGroup>
<EmbeddedResource Update="Assets/Locales/Resources.resx">
<Generator>MSBuild:Compile</Generator>
<StronglyTypedFileName>$(IntermediateOutputPath)Resources.Designer.cs</StronglyTypedFileName>
<StronglyTypedLanguage>CSharp</StronglyTypedLanguage>
<StronglyTypedNamespace>YKanBan</StronglyTypedNamespace>
<StronglyTypedClassName>Resources</StronglyTypedClassName>
<PublicClass>true</PublicClass>
<LogicalName>YKanBan.Resources.resources</LogicalName>
</EmbeddedResource>
<EmbeddedResource Update="Assets/Locales/Resources.zh-Hans.resx">
<LogicalName>YKanBan.Resources.zh-Hans.resources</LogicalName>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>