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,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