diff --git a/USAGE.md b/USAGE.md index e6dd7c2..6a4337a 100644 --- a/USAGE.md +++ b/USAGE.md @@ -14,9 +14,8 @@ YKanBan is a local/offline kanban board. It works like Git in one respect: all d ykanban ``` -- `` is the workspace folder to open. -- Starting without arguments, or with invalid arguments, shows the argument error page. -- If the path does not exist, the folder-missing page is shown. +- `` is the workspace folder to open. A relative path is resolved against the current working directory. +- Exactly one argument is required. Starting without arguments, with more than one argument, with a blank argument, with an argument starting with `-` (any option form, `--help` included), or with a path that does not exist or is not a folder shows the argument error page. - If the folder exists but has no `.ykanban`, the not-initialized page is shown with an **Initialize Workspace** button. Initializing creates the database and seeds the preset columns and tags in the current interface language. ## 2. Do not commit `.ykanban` to Git @@ -67,8 +66,8 @@ phrase := '"' any text '"' // escapes: \" to literal quote and - `tag:NAME` matches a tag by its **exact** name. - `column:TITLE` matches a column by its **exact** title. - `title:TEXT` matches a substring of the card title, and `content:TEXT` matches a substring of the card content. -- `id:N` matches a card by its **exact** numeric id. -- All matching is case-insensitive (SQLite ASCII semantics). +- `id:N` matches a card by its **exact** positive integer id; an optional leading `#` is allowed (`id:#61`). +- All matching is Unicode case-insensitive (both sides are case-folded before comparing). - Quotes allow values with spaces, for example `tag:"release notes"` or `column:"In Progress"`. - Inside a phrase, `\"` produces a literal quote and `\\` a literal backslash. @@ -82,9 +81,15 @@ The following are invalid: - a dangling operator, for example `bug AND`; - consecutive operators, for example `a AND OR b`; - a qualifier without a value, for example `tag:`; +- a qualifier phrase separated from its key by whitespace, for example `tag: "x"` (write `tag:"x"` instead); - an unterminated phrase, for example `"abc`; +- an empty phrase, for example `""`; +- a phrase glued to other text, for example `a"b"`; - an invalid escape inside a phrase (a backslash not followed by `"` or `\`), for example `tag:"a\x"`; -- an unknown qualifier key. Only `tag`, `title`, `content`, `id` and `column` are valid, and the key is case-sensitive, so `Tag:x` is an error. A word whose colon is not preceded by an identifier (for example `:foo`) is treated as plain text. +- a bare backslash in a word, for example `a\b`; +- an unquoted qualifier value containing `:` or equal to `AND`/`OR`, for example `tag:a:b` (quote the value instead); +- an id value that is not an unquoted positive integer with an optional `#`, for example `id:0`, `id:-1` or `id:"1"`; +- an unknown qualifier key. Only `tag`, `title`, `content`, `id` and `column` are valid, and the key is case-sensitive, so `Tag:x` is an error. A colon not preceded by a valid key (for example `:foo` or `http://x`) is an error; quote such text to search for it literally. ## 5. Export diff --git a/YKanBan.Tests/Launching/LaunchArgumentsTests.cs b/YKanBan.Tests/Launching/LaunchArgumentsTests.cs index 22c118a..85c01e6 100644 --- a/YKanBan.Tests/Launching/LaunchArgumentsTests.cs +++ b/YKanBan.Tests/Launching/LaunchArgumentsTests.cs @@ -1,10 +1,11 @@ using YKanBan.Launching; +using YKanBan.Tests.TestUtilities; namespace YKanBan.Tests.Launching; /// -/// Tests for the ykanban <path> command-line contract across its -/// three branches: no argument, one valid path, and invalid input. +/// Tests for the ykanban <path> command-line contract: exactly one +/// existing folder, resolved to an absolute path; everything else is rejected. /// [TestClass] public class LaunchArgumentsTests { @@ -27,9 +28,58 @@ public class LaunchArgumentsTests { } [TestMethod] - public void SinglePathIsAccepted() { - bool parsed = LaunchArguments.TryParse([@"C:\Repo\MyProject"], out string? path); + [DataRow("--help")] + [DataRow("-x")] + public void OptionLikeArgumentIsRejected(string argument) { + Assert.IsFalse(LaunchArguments.TryParse([argument], out string? path)); + Assert.IsNull(path); + } + + [TestMethod] + public void NonExistentFolderIsRejected() { + using var directory = new TempDirectory(); + string missing = Path.Combine(directory.FullPath, "nope"); + + Assert.IsFalse(LaunchArguments.TryParse([missing], out string? path)); + Assert.IsNull(path); + } + + [TestMethod] + public void FileInsteadOfFolderIsRejected() { + using var directory = new TempDirectory(); + string file = Path.Combine(directory.FullPath, "file.txt"); + File.WriteAllText(file, "x"); + + Assert.IsFalse(LaunchArguments.TryParse([file], out string? path)); + Assert.IsNull(path); + } + + [TestMethod] + public void ExistingFolderIsAcceptedAsAnAbsolutePath() { + using var directory = new TempDirectory(); + + bool parsed = LaunchArguments.TryParse([directory.FullPath], out string? path); + Assert.IsTrue(parsed); - Assert.AreEqual(@"C:\Repo\MyProject", path); + Assert.AreEqual(Path.GetFullPath(directory.FullPath), path); + Assert.IsTrue(Path.IsPathRooted(path)); + } + + [TestMethod] + public void RelativePathIsResolvedToAbsolute() { + using var directory = new TempDirectory(); + string relative = Path.GetRelativePath(Environment.CurrentDirectory, directory.FullPath); + + bool parsed = LaunchArguments.TryParse([relative], out string? path); + + Assert.IsTrue(parsed); + Assert.IsTrue(Path.IsPathRooted(path)); + Assert.AreEqual(Path.GetFullPath(relative), path); + } + + [TestMethod] + public void MalformedPathIsRejected() { + Assert.IsFalse(LaunchArguments.TryParse(["bad\0path"], out string? path)); + Assert.IsNull(path); } } diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx index 71f9f26..8c8dc9c 100644 --- a/YKanBan/Assets/Locales/Resources.resx +++ b/YKanBan/Assets/Locales/Resources.resx @@ -265,12 +265,6 @@ Usage: ykanban <path> - - Folder not found - - - The folder does not exist: {0} - No workspace here diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx index 55924e0..e51531e 100644 --- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -265,12 +265,6 @@ 用法:ykanban <路径> - - 目录不存在 - - - 目录不存在:{0} - 尚未初始化 diff --git a/YKanBan/Launching/LaunchArguments.cs b/YKanBan/Launching/LaunchArguments.cs index 488d96c..33ca181 100644 --- a/YKanBan/Launching/LaunchArguments.cs +++ b/YKanBan/Launching/LaunchArguments.cs @@ -4,9 +4,11 @@ namespace YKanBan.Launching; /// /// Parses raw command-line arguments. The only accepted form is a single, -/// non-blank workspace path (ykanban <path>); 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. +/// non-blank, existing workspace folder (ykanban <path>); zero +/// arguments, more than one argument, a blank argument, an argument starting +/// with - (any option form, --help included), a malformed path or +/// a path that is not an existing folder is rejected as an argument error. A +/// relative path is resolved against the process's current directory. /// public static class LaunchArguments { /// @@ -14,15 +16,28 @@ public static class LaunchArguments { /// ykanban <path> contract. /// /// The raw arguments, excluding the executable name. - /// The parsed workspace path on success, otherwise . - /// when exactly one non-blank path was supplied. + /// The absolute, existing workspace folder on success, otherwise . + /// when exactly one usable path was supplied. public static bool TryParse(IReadOnlyList arguments, [NotNullWhen(true)] out string? folderPath) { - if (arguments.Count == 1 && !string.IsNullOrWhiteSpace(arguments[0])) { - folderPath = arguments[0]; - return true; + folderPath = null; + if (arguments.Count != 1 || string.IsNullOrWhiteSpace(arguments[0]) || arguments[0].StartsWith('-')) { + return false; } - folderPath = null; - return false; + string fullPath; + try { + fullPath = Path.GetFullPath(arguments[0]); + } catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException) { + // A path the OS cannot even represent is a bad argument, not a missing folder. + return false; + } + + // Existence is checked here so every caller gets an already validated folder. + if (!Directory.Exists(fullPath)) { + return false; + } + + folderPath = fullPath; + return true; } } diff --git a/YKanBan/ViewModels/ErrorPages/DirectoryMissingPageViewModel.cs b/YKanBan/ViewModels/ErrorPages/DirectoryMissingPageViewModel.cs deleted file mode 100644 index a07a196..0000000 --- a/YKanBan/ViewModels/ErrorPages/DirectoryMissingPageViewModel.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Globalization; - -namespace YKanBan.ViewModels.ErrorPages; - -/// -/// The full-window page shown when the folder passed on the command line does -/// not exist. It has no operations. -/// -public sealed class DirectoryMissingPageViewModel : ViewModelBase { - /// - /// Initializes the page for a missing folder. - /// - /// The folder path that does not exist. - public DirectoryMissingPageViewModel(string folderPath) { - FolderPath = folderPath; - Message = string.Format( - Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_DirectoryMissing_Message, folderPath); - } - - /// - /// Gets the folder path that does not exist. - /// - public string FolderPath { get; } - - /// - /// Gets the localized explanatory message. - /// - public string Message { get; } -} diff --git a/YKanBan/ViewModels/MainWindowViewModel.cs b/YKanBan/ViewModels/MainWindowViewModel.cs index ad4f142..655448e 100644 --- a/YKanBan/ViewModels/MainWindowViewModel.cs +++ b/YKanBan/ViewModels/MainWindowViewModel.cs @@ -69,11 +69,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase { return; } - if (!Directory.Exists(folderPath)) { - Content = new DirectoryMissingPageViewModel(folderPath); - return; - } - try { OpenWorkspace(folderPath); } catch (WorkspaceNotInitializedException) { diff --git a/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml b/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml deleted file mode 100644 index caeed09..0000000 --- a/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml.cs b/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml.cs deleted file mode 100644 index a18f359..0000000 --- a/YKanBan/Views/ErrorPages/DirectoryMissingPageView.axaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Avalonia.Controls; - -namespace YKanBan.Views.ErrorPages; - -public partial class DirectoryMissingPageView : UserControl { - public DirectoryMissingPageView() { - InitializeComponent(); - } -}