diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c65245..7ddf074 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,41 @@ jobs: name: core-test-results path: TestResults/ + importer-tests: + name: Importer build + tests (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + cache: true + cache-dependency-path: | + **/packages.lock.json + **/*.csproj + + - name: Restore importer projects + run: | + dotnet restore tools/Snipdeck.Importer/Snipdeck.Importer.csproj + dotnet restore tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj + + - name: Build importer + run: dotnet build tools/Snipdeck.Importer/Snipdeck.Importer.csproj --configuration Release --no-restore + + - name: Build + run importer tests + run: dotnet test tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=importer-tests.trx" --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: importer-test-results + path: TestResults/ + app-build: name: App build (windows) runs-on: windows-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 952d441..b414d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 IL2104 on the WinAppSDK/WinRT/Jdenticon assemblies, which aren't trim-safe. ### Added +- **`snipdeck-importer` console tool.** A new cross-platform .NET tool + (`tools/Snipdeck.Importer`, installable as `snipdeck-importer`) imports command + snippets from a SnipCommand export into a Snipdeck store. It auto-suggests each + CLI from the command's first token, translates SnipCommand's inline + `[sc_choice]` / `[sc_variable]` markup into Snipdeck `{token}` placeholders plus + structured parameters, and defaults to a safe dry-run — `--write` backs the + store up first, mints fresh identifiers, creates CLIs on demand and skips + duplicates (`--allow-duplicates` to override). `--store`, `--cli` and `--into` + control the target. See [`tools/Snipdeck.Importer/README.md`](tools/Snipdeck.Importer/README.md). +- **Move a snip to a different CLI.** The snip editor now has a **CLI** selector, + so a snip can be re-homed to another CLI (useful after a bulk import lands snips + in a fallback CLI). +- **Discoverable importer in the first-run seed.** A new first-run **`snipdeck-importer`** + CLI sits alongside **Examples**, with snips that demonstrate the importer's own + commands. - **Redesigned Home page.** A gradient hero banner heads the page, the CLI launcher uses landscape tiles (232×172) showing each CLI's description, and a segmented selector below switches between **Most used**, **Recent** and diff --git a/Directory.Packages.props b/Directory.Packages.props index d0e5d0f..4540b15 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,7 +24,13 @@ - + + + + + + + diff --git a/README.md b/README.md index cce78d5..015b956 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,26 @@ on Windows 10 it falls back to a solid colour. ``` src/ - Snipdeck.Core/ net10.0 — UI-free domain, engine, store, services - Snipdeck.App/ net10.0-windows — WinUI 3 head, platform implementations + Snipdeck.Core/ net10.0 — UI-free domain, engine, store, services + Snipdeck.App/ net10.0-windows — WinUI 3 head, platform implementations tests/ - Snipdeck.Core.Tests/ net10.0 — xUnit coverage for Core + Snipdeck.Core.Tests/ net10.0 — xUnit coverage for Core +tools/ + Snipdeck.Importer/ net10.0 — snipdeck-importer console tool (SnipCommand import) + Snipdeck.Importer.Tests/ net10.0 — xUnit coverage for the importer ``` The dependency direction is one-way: `App → Core`. The view models live in Core and never touch WinUI types directly — every platform-bound capability is an interface defined in Core and implemented in App. +## Importing from SnipCommand + +Migrating from SnipCommand? The cross-platform `snipdeck-importer` console tool +reads a SnipCommand export and merges its snippets into your Snipdeck store, +grouping them under a CLI per command. It defaults to a dry-run preview; see +[`tools/Snipdeck.Importer/README.md`](tools/Snipdeck.Importer/README.md). + ## Building Requirements: @@ -75,7 +85,8 @@ dotnet test tests/Snipdeck.Core.Tests The `Snipdeck.Core` project targets `net10.0` and is fully portable, so `dotnet build` / `dotnet test` for Core also work on Linux and macOS. The -`Snipdeck.App` project is Windows-only. +`Snipdeck.App` project is Windows-only. The `Snipdeck.Importer` tool also targets +`net10.0` and builds, tests and runs on any platform. ## Licence diff --git a/Snipdeck.slnx b/Snipdeck.slnx index bfbea95..cc8d293 100644 --- a/Snipdeck.slnx +++ b/Snipdeck.slnx @@ -4,4 +4,6 @@ + + diff --git a/src/Snipdeck.App/Services/WindowsPathProvider.cs b/src/Snipdeck.App/Services/WindowsPathProvider.cs index e70ab19..a2103ce 100644 --- a/src/Snipdeck.App/Services/WindowsPathProvider.cs +++ b/src/Snipdeck.App/Services/WindowsPathProvider.cs @@ -1,37 +1,23 @@ using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Services; namespace Snipdeck.App.Services { /// /// Resolves Snipdeck's data paths under %LOCALAPPDATA%\Snipdeck. + /// The layout lives in (Core) so the cross-platform + /// importer tool resolves identical locations; this provider simply delegates. /// internal sealed class WindowsPathProvider : IPathProvider { - private const string _appFolderName = "Snipdeck"; - private const string _settingsFileName = "settings.json"; - private const string _storeDirectoryName = "store"; - private const string _backupsDirectoryName = "backups"; - private const string _logsDirectoryName = "logs"; + public string AppDataDirectory => DefaultPaths.AppDataDirectory; - public WindowsPathProvider() - { - AppDataDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - _appFolderName); - SettingsFilePath = Path.Combine(AppDataDirectory, _settingsFileName); - DefaultStorageDirectory = Path.Combine(AppDataDirectory, _storeDirectoryName); - DefaultBackupDirectory = Path.Combine(AppDataDirectory, _backupsDirectoryName); - LogsDirectory = Path.Combine(AppDataDirectory, _logsDirectoryName); - } + public string SettingsFilePath => DefaultPaths.SettingsFilePath; - public string AppDataDirectory { get; } + public string DefaultStorageDirectory => DefaultPaths.DefaultStorageDirectory; - public string SettingsFilePath { get; } + public string DefaultBackupDirectory => DefaultPaths.DefaultBackupDirectory; - public string DefaultStorageDirectory { get; } - - public string DefaultBackupDirectory { get; } - - public string LogsDirectory { get; } + public string LogsDirectory => DefaultPaths.LogsDirectory; } } diff --git a/src/Snipdeck.App/Services/WindowsShellInteractions.cs b/src/Snipdeck.App/Services/WindowsShellInteractions.cs index e669c2c..1104470 100644 --- a/src/Snipdeck.App/Services/WindowsShellInteractions.cs +++ b/src/Snipdeck.App/Services/WindowsShellInteractions.cs @@ -72,7 +72,7 @@ public async Task NotifyAsync(string title, string message, string buttonText = public async Task EditSnipAsync(Snip snip, IReadOnlyList availableClis) { ArgumentNullException.ThrowIfNull(snip); - var editor = new SnipEditorViewModel(snip); + var editor = new SnipEditorViewModel(snip, availableClis); var dialog = new SnipEditorDialog(editor) { XamlRoot = GetXamlRoot(), diff --git a/src/Snipdeck.App/Views/SnipEditorDialog.xaml b/src/Snipdeck.App/Views/SnipEditorDialog.xaml index 06365f1..7edd8ff 100644 --- a/src/Snipdeck.App/Views/SnipEditorDialog.xaml +++ b/src/Snipdeck.App/Views/SnipEditorDialog.xaml @@ -26,6 +26,18 @@ + + + + + + + + + + /// Normalises a comma-separated tag string into a tag list: split on commas, trim, drop + /// empties, and de-duplicate case-insensitively. Shared by the snip editor and the importer + /// so a tag list is normalised the same way wherever it enters the store. + /// + public static class TagParser + { + public static List Parse(string? text) + { + return string.IsNullOrWhiteSpace(text) + ? [] + : [.. text + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase)]; + } + } +} diff --git a/src/Snipdeck.Core/Services/DefaultPaths.cs b/src/Snipdeck.Core/Services/DefaultPaths.cs new file mode 100644 index 0000000..998909d --- /dev/null +++ b/src/Snipdeck.Core/Services/DefaultPaths.cs @@ -0,0 +1,50 @@ +namespace Snipdeck.Core.Services +{ + /// + /// Resolves Snipdeck's default data paths under %LOCALAPPDATA%\Snipdeck (Windows) + /// or the platform equivalent of . + /// + /// This lives in Core so that both the WinUI head and the cross-platform importer tool + /// resolve the same locations without duplicating the layout. The desktop app's + /// WindowsPathProvider delegates here, so its behaviour is unchanged. + /// + /// + public static class DefaultPaths + { + public const string AppFolderName = "Snipdeck"; + public const string SettingsFileName = "settings.json"; + public const string StoreDirectoryName = "store"; + public const string StoreFileName = "store.json"; + public const string BackupsDirectoryName = "backups"; + public const string LogsDirectoryName = "logs"; + + /// The Snipdeck data root, e.g. %LOCALAPPDATA%\Snipdeck. + public static string AppDataDirectory { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + AppFolderName); + + /// The app-config file, stored separately from the snip store. + public static string SettingsFilePath { get; } = Path.Combine(AppDataDirectory, SettingsFileName); + + /// The default directory that holds the snip store document. + public static string DefaultStorageDirectory { get; } = Path.Combine(AppDataDirectory, StoreDirectoryName); + + /// The default directory that holds timestamped store backups. + public static string DefaultBackupDirectory { get; } = Path.Combine(AppDataDirectory, BackupsDirectoryName); + + /// The default directory that holds application logs. + public static string LogsDirectory { get; } = Path.Combine(AppDataDirectory, LogsDirectoryName); + + /// + /// Composes the full default store-document path (<storage>/store.json) from an + /// optional configured storage directory, falling back to . + /// + public static string ResolveStoreFilePath(string? configuredStorageDirectory) + { + var storageDirectory = string.IsNullOrWhiteSpace(configuredStorageDirectory) + ? DefaultStorageDirectory + : configuredStorageDirectory; + return Path.Combine(storageDirectory, StoreFileName); + } + } +} diff --git a/src/Snipdeck.Core/Services/ExamplesSeed.cs b/src/Snipdeck.Core/Services/ExamplesSeed.cs index 9aa3fcf..18b73f5 100644 --- a/src/Snipdeck.Core/Services/ExamplesSeed.cs +++ b/src/Snipdeck.Core/Services/ExamplesSeed.cs @@ -6,6 +6,7 @@ namespace Snipdeck.Core.Services public static class ExamplesSeed { public const string CliName = "Examples"; + public const string ImporterCliName = "snipdeck-importer"; public static bool IsEmpty(SnipStoreDocument document) { @@ -14,13 +15,22 @@ public static bool IsEmpty(SnipStoreDocument document) } public static SnipStoreDocument Build() + { + var document = new SnipStoreDocument(); + AddExamplesCli(document); + AddImporterCli(document); + + ValidateInternalConsistency(document); + return document; + } + + private static void AddExamplesCli(SnipStoreDocument document) { var cli = new Cli { Name = CliName, Description = "A starter CLI with a few representative snips. Delete it once you're oriented.", }; - var document = new SnipStoreDocument(); document.Clis.Add(cli); document.Snips.Add(new Snip @@ -84,9 +94,83 @@ public static SnipStoreDocument Build() }, }, }); + } - ValidateInternalConsistency(document); - return document; + private static void AddImporterCli(SnipStoreDocument document) + { + var cli = new Cli + { + Name = ImporterCliName, + Description = "The Snipdeck importer command-line tool. Bring snips in from SnipCommand and other sources.", + }; + document.Clis.Add(cli); + + document.Snips.Add(new Snip + { + CliId = cli.Id, + Title = "Preview a SnipCommand import", + CommandTemplate = "snipdeck-importer snipcommand {path}", + Description = "Parses a SnipCommand export and prints the planned additions without touching the store. The safe default — nothing is written until you add --write.", + Tags = { "import", "snipcommand" }, + Parameters = + { + new Parameter + { + Name = "path", + Type = ParameterType.Text, + Default = "snipcommand.db", + }, + }, + }); + + document.Snips.Add(new Snip + { + CliId = cli.Id, + Title = "Import from SnipCommand", + CommandTemplate = "snipdeck-importer snipcommand {path} --write", + Description = "Merges the SnipCommand export into the Snipdeck store. Backs the store up first, mints fresh identifiers, and skips snips that already exist.", + Tags = { "import", "snipcommand" }, + IsFavourite = true, + Parameters = + { + new Parameter + { + Name = "path", + Type = ParameterType.Text, + Default = "snipcommand.db", + }, + }, + }); + + document.Snips.Add(new Snip + { + CliId = cli.Id, + Title = "Import into a specific store and CLI", + CommandTemplate = "snipdeck-importer snipcommand {path} --store {store} --cli {cli}", + Description = "Imports into an explicit store file and forces every snip into one named CLI, overriding the per-command auto-suggestion.", + Tags = { "import", "snipcommand" }, + Parameters = + { + new Parameter + { + Name = "path", + Type = ParameterType.Text, + Default = "snipcommand.db", + }, + new Parameter + { + Name = "store", + Type = ParameterType.Text, + Default = "store.json", + }, + new Parameter + { + Name = "cli", + Type = ParameterType.Text, + Default = "imported", + }, + }, + }); } private static void ValidateInternalConsistency(SnipStoreDocument document) diff --git a/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs b/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs index b08ed37..5d9d1ee 100644 --- a/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs @@ -2,13 +2,14 @@ using CommunityToolkit.Mvvm.ComponentModel; +using Snipdeck.Core.Engine; using Snipdeck.Core.Models; namespace Snipdeck.Core.ViewModels { public sealed partial class SnipEditorViewModel : ObservableObject { - public SnipEditorViewModel(Snip snip) + public SnipEditorViewModel(Snip snip, IReadOnlyList? availableClis = null) { ArgumentNullException.ThrowIfNull(snip); @@ -19,10 +20,23 @@ public SnipEditorViewModel(Snip snip) TagsText = string.Join(", ", snip.Tags); Parameters = new ObservableCollection( snip.Parameters.Select(p => new ParameterEditorRowViewModel(p))); + + AvailableClis = new ObservableCollection(availableClis ?? []); + SelectedCli = AvailableClis.FirstOrDefault(c => c.Id == snip.CliId); } public Snip Snip { get; } + /// The CLIs the snip may belong to, so the user can re-home it. + public ObservableCollection AvailableClis { get; } + + /// + /// The CLI the snip will belong to once saved. Initialised from the snip's current + /// CLI; changing it moves the snip to a different CLI. + /// + [ObservableProperty] + public partial Cli? SelectedCli { get; set; } + [ObservableProperty] public partial string Title { get; set; } = string.Empty; @@ -55,11 +69,11 @@ public Snip BuildUpdatedSnip() return new Snip { Id = Snip.Id, - CliId = Snip.CliId, + CliId = SelectedCli?.Id ?? Snip.CliId, Title = Title.Trim(), CommandTemplate = CommandTemplate.Trim(), Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(), - Tags = ParseTags(TagsText), + Tags = TagParser.Parse(TagsText), IsFavourite = Snip.IsFavourite, IsTrash = Snip.IsTrash, UsageCount = Snip.UsageCount, @@ -67,14 +81,5 @@ public Snip BuildUpdatedSnip() Parameters = [.. Parameters.Select(r => r.BuildParameter())], }; } - - private static List ParseTags(string text) - { - return string.IsNullOrWhiteSpace(text) - ? [] - : [.. text - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase)]; - } } } diff --git a/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs b/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs index 591462e..3a7fa58 100644 --- a/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs +++ b/tests/Snipdeck.Core.Tests/Services/ExamplesSeedTests.cs @@ -20,23 +20,28 @@ public void IsEmpty_is_false_after_Build() } [Fact] - public void Build_produces_a_single_cli_named_Examples() + public void Build_produces_the_examples_and_importer_clis() { var doc = ExamplesSeed.Build(); - var cli = Assert.Single(doc.Clis); - Assert.Equal(ExamplesSeed.CliName, cli.Name); - Assert.NotEqual(Guid.Empty, cli.Id); + Assert.Equal(2, doc.Clis.Count); + Assert.Contains(doc.Clis, c => c.Name == ExamplesSeed.CliName); + Assert.Contains(doc.Clis, c => c.Name == ExamplesSeed.ImporterCliName); + Assert.All(doc.Clis, c => Assert.NotEqual(Guid.Empty, c.Id)); + Assert.Equal(doc.Clis.Count, doc.Clis.Select(c => c.Id).Distinct().Count()); } [Fact] - public void Build_produces_multiple_snips_all_belonging_to_the_examples_cli() + public void Build_produces_snips_that_each_belong_to_a_seeded_cli() { var doc = ExamplesSeed.Build(); - var cliId = doc.Clis.Single().Id; + var cliIds = doc.Clis.Select(c => c.Id).ToHashSet(); Assert.NotEmpty(doc.Snips); - Assert.All(doc.Snips, snip => Assert.Equal(cliId, snip.CliId)); + Assert.All(doc.Snips, snip => Assert.Contains(snip.CliId, cliIds)); + + // Both seeded CLIs carry at least one snip. + Assert.All(doc.Clis, cli => Assert.Contains(doc.Snips, s => s.CliId == cli.Id)); } [Fact] diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs index c4f8da4..fb0031f 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs @@ -174,6 +174,42 @@ public async Task EditSnip_replaces_the_snip_in_the_store_when_interactions_retu Assert.Equal(1, store.SaveCount); } + [Fact] + public async Task EditSnip_moves_the_snip_to_a_different_cli_when_the_cli_id_changes() + { + Cli source = null!; + Snip snip = null!; + var target = new Cli { Name = "other-app" }; + var (vm, store, _, ix, _) = await BuildAsync(d => + { + (source, snip) = SeedOneCliOneSnip(d); + d.Clis.Add(target); + }); + + // The editor returns the snip re-homed to a different CLI. + ix.NextSnipEditResult = new SnipEditResult(new Snip + { + Id = snip.Id, + CliId = target.Id, + Title = snip.Title, + CommandTemplate = snip.CommandTemplate, + }); + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == source.Id); + var card = ((CliViewModel)vm.CurrentContent!).Snips[0]; + + await vm.EditSnipCommand.ExecuteAsync(card); + + Assert.Equal(target.Id, store.Document.Snips[0].CliId); + + // The moved snip no longer appears under its old CLI. + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == source.Id); + Assert.Empty(((CliViewModel)vm.CurrentContent!).Snips); + + // ...and now appears under the target CLI. + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == target.Id); + Assert.Single(((CliViewModel)vm.CurrentContent!).Snips); + } + [Fact] public async Task DeleteSnip_marks_as_trash_only_when_confirmed() { diff --git a/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs index 1f6d62f..a5b42ed 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs @@ -107,5 +107,43 @@ public void BuildUpdatedSnip_preserves_identity_and_applies_edits() Assert.Single(built.Parameters); Assert.Equal("x", built.Parameters[0].Name); } + + [Fact] + public void SelectedCli_initialises_from_the_snips_current_cli() + { + var examples = new Cli { Name = "Examples" }; + var other = new Cli { Name = "other" }; + var snip = new Snip { CliId = other.Id, Title = "t", CommandTemplate = "echo" }; + + var vm = new SnipEditorViewModel(snip, [examples, other]); + + Assert.Same(other, vm.SelectedCli); + } + + [Fact] + public void BuildUpdatedSnip_moves_the_snip_to_the_selected_cli() + { + var source = new Cli { Name = "source" }; + var target = new Cli { Name = "target" }; + var snip = new Snip { CliId = source.Id, Title = "t", CommandTemplate = "echo" }; + var vm = new SnipEditorViewModel(snip, [source, target]) + { + SelectedCli = target, + }; + + var built = vm.BuildUpdatedSnip(); + + Assert.Equal(target.Id, built.CliId); + } + + [Fact] + public void BuildUpdatedSnip_keeps_the_original_cli_when_no_clis_are_supplied() + { + var snip = new Snip { CliId = Guid.NewGuid(), Title = "t", CommandTemplate = "echo" }; + + var built = new SnipEditorViewModel(snip).BuildUpdatedSnip(); + + Assert.Equal(snip.CliId, built.CliId); + } } } diff --git a/tools/Snipdeck.Importer.Tests/CliSuggesterTests.cs b/tools/Snipdeck.Importer.Tests/CliSuggesterTests.cs new file mode 100644 index 0000000..4df2c3b --- /dev/null +++ b/tools/Snipdeck.Importer.Tests/CliSuggesterTests.cs @@ -0,0 +1,55 @@ +using Snipdeck.Importer.Translation; + +namespace Snipdeck.Importer.Tests +{ + public class CliSuggesterTests + { + [Theory] + [InlineData("mpt-app orders delete \"x.txt\"", "mpt-app")] + [InlineData("inv-app validate invoices", "inv-app")] + [InlineData("pip install --index-url https://x mpt-cli", "pip")] + public void First_token_is_the_suggested_cli(string command, string expected) + { + var result = CliSuggester.Suggest(command); + Assert.True(result.Confident); + Assert.Equal(expected, result.Name); + } + + [Theory] + [InlineData("sudo systemctl restart x", "systemctl")] + [InlineData("npx create-react-app foo", "create-react-app")] + [InlineData("env FOO=bar mytool run", "mytool")] + public void Leading_wrappers_are_peeled(string command, string expected) + { + var result = CliSuggester.Suggest(command); + Assert.True(result.Confident); + Assert.Equal(expected, result.Name); + } + + [Theory] + [InlineData("pip install x")] + [InlineData("python -m build")] + [InlineData("dotnet build")] + public void Language_runtimes_are_not_peeled(string command) + { + var first = command.Split(' ')[0]; + var result = CliSuggester.Suggest(command); + Assert.Equal(first, result.Name); + } + + [Fact] + public void Empty_command_is_not_confident() + { + var result = CliSuggester.Suggest(" "); + Assert.False(result.Confident); + Assert.Equal(string.Empty, result.Name); + } + + [Fact] + public void A_parameterised_leading_token_is_not_confident() + { + var result = CliSuggester.Suggest("{tool} run"); + Assert.False(result.Confident); + } + } +} diff --git a/tools/Snipdeck.Importer.Tests/Fixtures/snipcommand-sample.db b/tools/Snipdeck.Importer.Tests/Fixtures/snipcommand-sample.db new file mode 100644 index 0000000..9180941 --- /dev/null +++ b/tools/Snipdeck.Importer.Tests/Fixtures/snipcommand-sample.db @@ -0,0 +1,51 @@ +{ + "commands": [ + { + "title": "Get latest mpt cli", + "command": "pip install --index-url https://example/simple/ mpt-cli==1.0.0", + "tags": "billing,adobe", + "description": "", + "isFavourite": false, + "isTrash": false, + "id": "Yyv0hhIcz" + }, + { + "title": "Delete marketplace orders", + "tags": "mpt,orders", + "command": "mpt-app orders delete \"D:\\Working\\repairs\\ordersToDelete.txt\"", + "description": "", + "isFavourite": true, + "isTrash": false, + "id": "GlBRiwFzR", + "usageCount": 4, + "lastUsedAt": "2025-09-12T10:15:30Z" + }, + { + "title": "Export subscriptions", + "command": "mpt-app adobe subscriptions export [sc_choice name=\"authId\" value=\"auth-adobe-ca-01,auth-adobe-de-01,auth-adobe-uk-01\" /]", + "tags": "adobe,subscriptions,export", + "description": "Exports subscriptions for an authorisation.", + "isFavourite": false, + "isTrash": false, + "id": "abc123" + }, + { + "title": "Request 3YC", + "command": "mpt-app adobe agreements request-3yc [sc_variable name=\"Agreement ID\" value=\"\" /] [sc_choice name=\"authId\" value=\"auth-adobe-ca-01,auth-adobe-de-01\" /]", + "tags": "adobe,3yc", + "description": "", + "isFavourite": false, + "isTrash": false, + "id": "def456" + }, + { + "title": "An old trashed snip", + "command": "mpt-app deprecated thing", + "tags": "", + "description": "", + "isFavourite": false, + "isTrash": true, + "id": "trash01" + } + ] +} diff --git a/tools/Snipdeck.Importer.Tests/ScMarkupTranslatorTests.cs b/tools/Snipdeck.Importer.Tests/ScMarkupTranslatorTests.cs new file mode 100644 index 0000000..dde297c --- /dev/null +++ b/tools/Snipdeck.Importer.Tests/ScMarkupTranslatorTests.cs @@ -0,0 +1,157 @@ +using Snipdeck.Core.Engine; +using Snipdeck.Core.Models; +using Snipdeck.Importer.Translation; + +namespace Snipdeck.Importer.Tests +{ + public class ScMarkupTranslatorTests + { + [Fact] + public void Plain_command_with_no_markup_is_unchanged_and_has_no_parameters() + { + var result = ScMarkupTranslator.Translate("mpt-app orders delete \"D:\\Working\\x.txt\""); + + Assert.Equal("mpt-app orders delete \"D:\\Working\\x.txt\"", result.CommandTemplate); + Assert.Empty(result.Parameters); + } + + [Fact] + public void Choice_markup_becomes_a_choice_parameter_with_options_and_first_default() + { + var result = ScMarkupTranslator.Translate( + "mpt-app export [sc_choice name=\"authId\" value=\"a-01,b-02,c-03\" /]"); + + Assert.Equal("mpt-app export {authId}", result.CommandTemplate); + var p = Assert.Single(result.Parameters); + Assert.Equal("authId", p.Name); + Assert.Equal(ParameterType.Choice, p.Type); + Assert.Equal(["a-01", "b-02", "c-03"], p.Options); + Assert.Equal("a-01", p.Default); + } + + [Fact] + public void Variable_markup_becomes_a_text_parameter_with_its_value_as_default() + { + var result = ScMarkupTranslator.Translate( + "tool [sc_variable name=\"customerId\" value=\"1005\" /]"); + + Assert.Equal("tool {customerId}", result.CommandTemplate); + var p = Assert.Single(result.Parameters); + Assert.Equal("customerId", p.Name); + Assert.Equal(ParameterType.Text, p.Type); + Assert.Equal("1005", p.Default); + } + + [Fact] + public void Empty_variable_value_yields_a_null_default() + { + var result = ScMarkupTranslator.Translate( + "tool [sc_variable name=\"Agreement ID\" value=\"\" /]"); + + var p = Assert.Single(result.Parameters); + Assert.Null(p.Default); + } + + [Theory] + [InlineData("Agreement ID", "AgreementID")] + [InlineData("Order ID / Agreement ID", "OrderIDAgreementID")] + [InlineData("License Quantity", "LicenseQuantity")] + [InlineData("VIP Membership No", "VIPMembershipNo")] + public void Names_with_spaces_and_punctuation_are_slugified_to_legal_tokens(string name, string expectedToken) + { + var result = ScMarkupTranslator.Translate( + $"tool [sc_variable name=\"{name}\" value=\"\" /]"); + + Assert.Equal($"tool {{{expectedToken}}}", result.CommandTemplate); + Assert.Equal(expectedToken, Assert.Single(result.Parameters).Name); + } + + [Fact] + public void Already_legal_names_are_preserved_verbatim() + { + var result = ScMarkupTranslator.Translate("tool [sc_variable name=\"authId\" value=\"x\" /]"); + Assert.Equal("authId", Assert.Single(result.Parameters).Name); + } + + [Fact] + public void A_name_repeated_in_one_command_maps_to_a_single_token_and_parameter() + { + var result = ScMarkupTranslator.Translate( + "tool [sc_variable name=\"Agreement ID\" value=\"\" /] then [sc_variable name=\"Agreement ID\" value=\"\" /]"); + + Assert.Equal("tool {AgreementID} then {AgreementID}", result.CommandTemplate); + Assert.Single(result.Parameters); + } + + [Fact] + public void Distinct_names_that_slug_to_the_same_token_are_disambiguated() + { + // "Order ID" and "Order-ID" both slugify to "OrderID". + var result = ScMarkupTranslator.Translate( + "tool [sc_variable name=\"Order ID\" value=\"\" /] [sc_variable name=\"Order-ID\" value=\"\" /]"); + + Assert.Equal("tool {OrderID} {OrderID_2}", result.CommandTemplate); + Assert.Equal(2, result.Parameters.Count); + } + + [Fact] + public void Multiple_mixed_markups_in_one_command_are_all_translated() + { + var result = ScMarkupTranslator.Translate( + "mpt-app adobe agreements request-3yc [sc_variable name=\"Agreement ID\" value=\"\" /] [sc_choice name=\"authId\" value=\"a,b\" /]"); + + Assert.Equal("mpt-app adobe agreements request-3yc {AgreementID} {authId}", result.CommandTemplate); + Assert.Equal(2, result.Parameters.Count); + } + + [Fact] + public void Every_emitted_token_is_backed_by_a_parameter() + { + var result = ScMarkupTranslator.Translate( + "mpt-app x [sc_choice name=\"authId\" value=\"a,b\" /] [sc_variable name=\"Licensee IDs\" value=\"LCE-\" /]"); + + var defined = result.Parameters.Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + foreach (var token in SubstitutionEngine.ExtractTokens(result.CommandTemplate)) + { + Assert.Contains(token, defined); + } + } + + [Fact] + public void Malformed_markup_without_a_name_is_left_verbatim() + { + const string command = "tool [sc_variable value=\"x\" /]"; + var result = ScMarkupTranslator.Translate(command); + + Assert.Equal(command, result.CommandTemplate); + Assert.Empty(result.Parameters); + } + + [Fact] + public void Non_self_closing_markup_is_left_verbatim() + { + const string command = "tool [sc_variable name=\"x\" value=\"1\"]"; + var result = ScMarkupTranslator.Translate(command); + + Assert.Equal(command, result.CommandTemplate); + Assert.Empty(result.Parameters); + } + + [Fact] + public void Control_and_escape_characters_are_stripped_from_values() + { + var result = ScMarkupTranslator.Translate( + "tool [sc_variable name=\"x\" value=\"ab\tc\" /]"); + + // ESC and tab removed; visible characters preserved. + Assert.Equal("a[31mbc", Assert.Single(result.Parameters).Default); + } + + [Fact] + public void Null_or_empty_command_is_handled_gracefully() + { + Assert.Equal(string.Empty, ScMarkupTranslator.Translate(null).CommandTemplate); + Assert.Empty(ScMarkupTranslator.Translate("").Parameters); + } + } +} diff --git a/tools/Snipdeck.Importer.Tests/SnipCommandSourceTests.cs b/tools/Snipdeck.Importer.Tests/SnipCommandSourceTests.cs new file mode 100644 index 0000000..f3099b1 --- /dev/null +++ b/tools/Snipdeck.Importer.Tests/SnipCommandSourceTests.cs @@ -0,0 +1,91 @@ +using Snipdeck.Core.Models; +using Snipdeck.Importer.Sources; + +namespace Snipdeck.Importer.Tests +{ + public class SnipCommandSourceTests + { + private static readonly string _fixturePath = + Path.Combine(AppContext.BaseDirectory, "Fixtures", "snipcommand-sample.db"); + + [Fact] + public void Reads_the_sample_export_skipping_trashed_entries() + { + var candidates = new SnipCommandSource().Read(_fixturePath); + + // 5 entries in the fixture, one of which is trashed. + Assert.Equal(4, candidates.Count); + Assert.DoesNotContain(candidates, c => c.Snip.Title == "An old trashed snip"); + } + + [Fact] + public void Suggests_the_cli_from_the_first_token() + { + var candidates = new SnipCommandSource().Read(_fixturePath); + + var pip = candidates.Single(c => c.Snip.Title == "Get latest mpt cli"); + Assert.Equal("pip", pip.SuggestedCliName); + Assert.True(pip.CliConfident); + + var mpt = candidates.Single(c => c.Snip.Title == "Delete marketplace orders"); + Assert.Equal("mpt-app", mpt.SuggestedCliName); + } + + [Fact] + public void Carries_tags_favourite_usage_and_last_used() + { + var candidates = new SnipCommandSource().Read(_fixturePath); + var mpt = candidates.Single(c => c.Snip.Title == "Delete marketplace orders"); + + Assert.True(mpt.Snip.IsFavourite); + Assert.Equal(["mpt", "orders"], mpt.Snip.Tags); + Assert.Equal(4, mpt.Snip.UsageCount); + Assert.Equal(new DateTimeOffset(2025, 9, 12, 10, 15, 30, TimeSpan.Zero), mpt.Snip.LastUsedAt); + } + + [Fact] + public void Empty_description_and_tags_become_null_and_empty() + { + var candidates = new SnipCommandSource().Read(_fixturePath); + var pip = candidates.Single(c => c.Snip.Title == "Get latest mpt cli"); + + Assert.Null(pip.Snip.Description); + Assert.Equal(["billing", "adobe"], pip.Snip.Tags); + } + + [Fact] + public void Translates_markup_into_tokens_and_parameters() + { + var candidates = new SnipCommandSource().Read(_fixturePath); + + var export = candidates.Single(c => c.Snip.Title == "Export subscriptions"); + Assert.Equal("mpt-app adobe subscriptions export {authId}", export.Snip.CommandTemplate); + var choice = Assert.Single(export.Snip.Parameters); + Assert.Equal(ParameterType.Choice, choice.Type); + Assert.Equal(3, choice.Options.Count); + + var threeYc = candidates.Single(c => c.Snip.Title == "Request 3YC"); + Assert.Equal("mpt-app adobe agreements request-3yc {AgreementID} {authId}", threeYc.Snip.CommandTemplate); + Assert.Equal(2, threeYc.Snip.Parameters.Count); + } + + [Fact] + public void Non_json_content_is_rejected_with_a_friendly_message() + { + var ex = Assert.Throws(() => SnipCommandSource.ReadFromText("SQLite format 3")); + Assert.Contains("SnipCommand", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Malformed_json_is_rejected_with_a_friendly_message() + { + Assert.Throws(() => SnipCommandSource.ReadFromText("{ \"commands\": [ ")); + } + + [Fact] + public void Missing_file_throws_file_not_found() + { + Assert.Throws(() => new SnipCommandSource().Read("/no/such/file.db")); + } + } +} diff --git a/tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj b/tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj new file mode 100644 index 0000000..d188c32 --- /dev/null +++ b/tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + false + + $(NoWarn);CA1707;CA1861;IDE0300;IDE0058 + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/Snipdeck.Importer.Tests/StoreMergerTests.cs b/tools/Snipdeck.Importer.Tests/StoreMergerTests.cs new file mode 100644 index 0000000..1317e48 --- /dev/null +++ b/tools/Snipdeck.Importer.Tests/StoreMergerTests.cs @@ -0,0 +1,200 @@ +using Snipdeck.Core.Models; +using Snipdeck.Importer.Merge; +using Snipdeck.Importer.Sources; + +namespace Snipdeck.Importer.Tests +{ + public class StoreMergerTests + { + private static readonly MergeOptions _defaults = new(ForceCli: null, Into: null, AllowDuplicates: false); + + private static SnippetCandidate Candidate(string cli, string title, string template, bool confident = true) + { + return new SnippetCandidate(cli, confident, new Snip { Title = title, CommandTemplate = template }); + } + + [Fact] + public void New_cli_is_created_on_demand_and_snips_are_attached_to_it() + { + var doc = new SnipStoreDocument(); + var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], _defaults); + + Assert.Equal(["mpt-app"], plan.ClisToCreate); + Assert.Equal(1, plan.ImportCount); + + StoreMerger.Apply(doc, plan); + + var cli = Assert.Single(doc.Clis); + Assert.Equal("mpt-app", cli.Name); + Assert.NotEqual(Guid.Empty, cli.Id); + Assert.Equal(cli.Id, Assert.Single(doc.Snips).CliId); + } + + [Fact] + public void Existing_cli_is_reused_case_insensitively() + { + var existing = new Cli { Name = "MPT-App" }; + var doc = new SnipStoreDocument { Clis = { existing } }; + + var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], _defaults); + Assert.Empty(plan.ClisToCreate); + + StoreMerger.Apply(doc, plan); + + Assert.Single(doc.Clis); + Assert.Equal(existing.Id, Assert.Single(doc.Snips).CliId); + } + + [Fact] + public void Multiple_snips_for_the_same_new_cli_create_it_once() + { + var doc = new SnipStoreDocument(); + var plan = StoreMerger.Plan( + doc, + [ + Candidate("mpt-app", "A", "mpt-app a"), + Candidate("mpt-app", "B", "mpt-app b"), + ], + _defaults); + + Assert.Equal(["mpt-app"], plan.ClisToCreate); + + StoreMerger.Apply(doc, plan); + + Assert.Single(doc.Clis); + Assert.Equal(2, doc.Snips.Count); + Assert.All(doc.Snips, s => Assert.Equal(doc.Clis[0].Id, s.CliId)); + } + + [Fact] + public void Duplicate_against_existing_store_is_skipped_by_default() + { + var cli = new Cli { Name = "mpt-app" }; + var doc = new SnipStoreDocument + { + Clis = { cli }, + Snips = { new Snip { CliId = cli.Id, Title = "Delete", CommandTemplate = "mpt-app orders delete" } }, + }; + + var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], _defaults); + + Assert.Equal(0, plan.ImportCount); + Assert.Equal(1, plan.SkipCount); + Assert.Empty(plan.ClisToCreate); + + StoreMerger.Apply(doc, plan); + Assert.Single(doc.Snips); + } + + [Fact] + public void A_trashed_existing_snip_does_not_block_the_import() + { + var cli = new Cli { Name = "mpt-app" }; + var doc = new SnipStoreDocument + { + Clis = { cli }, + Snips = { new Snip { CliId = cli.Id, Title = "Delete", CommandTemplate = "mpt-app orders delete", IsTrash = true } }, + }; + + var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], _defaults); + Assert.Equal(1, plan.ImportCount); + } + + [Fact] + public void Same_title_and_command_under_a_different_cli_is_not_a_duplicate() + { + var other = new Cli { Name = "other-app" }; + var doc = new SnipStoreDocument + { + Clis = { other }, + Snips = { new Snip { CliId = other.Id, Title = "Delete", CommandTemplate = "mpt-app orders delete" } }, + }; + + // Same title+command, but it auto-suggests CLI "mpt-app" — a different CLI from the existing snip. + var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], _defaults); + + Assert.Equal(1, plan.ImportCount); + Assert.Equal(["mpt-app"], plan.ClisToCreate); + } + + [Fact] + public void AllowDuplicates_imports_even_when_a_match_exists() + { + var cli = new Cli { Name = "mpt-app" }; + var doc = new SnipStoreDocument + { + Clis = { cli }, + Snips = { new Snip { CliId = cli.Id, Title = "Delete", CommandTemplate = "mpt-app orders delete" } }, + }; + + var options = _defaults with { AllowDuplicates = true }; + var plan = StoreMerger.Plan(doc, [Candidate("mpt-app", "Delete", "mpt-app orders delete")], options); + + Assert.Equal(1, plan.ImportCount); + StoreMerger.Apply(doc, plan); + Assert.Equal(2, doc.Snips.Count); + } + + [Fact] + public void Identical_candidates_within_one_batch_are_deduped() + { + var doc = new SnipStoreDocument(); + var plan = StoreMerger.Plan( + doc, + [ + Candidate("mpt-app", "Delete", "mpt-app orders delete"), + Candidate("mpt-app", "Delete", "mpt-app orders delete"), + ], + _defaults); + + Assert.Equal(1, plan.ImportCount); + Assert.Equal(1, plan.SkipCount); + } + + [Fact] + public void ForceCli_overrides_the_suggested_cli_for_every_candidate() + { + var doc = new SnipStoreDocument(); + var options = _defaults with { ForceCli = "bucket" }; + var plan = StoreMerger.Plan( + doc, + [ + Candidate("mpt-app", "A", "mpt-app a"), + Candidate("inv-app", "B", "inv-app b"), + ], + options); + + Assert.All(plan.Items, i => Assert.Equal("bucket", i.TargetCliName)); + Assert.Equal(["bucket"], plan.ClisToCreate); + } + + [Fact] + public void Into_is_used_only_for_unconfident_suggestions() + { + var doc = new SnipStoreDocument(); + var options = _defaults with { Into = "fallback" }; + var plan = StoreMerger.Plan( + doc, + [ + Candidate("mpt-app", "A", "mpt-app a", confident: true), + new SnippetCandidate(string.Empty, false, new Snip { Title = "B", CommandTemplate = "{x} b" }), + ], + options); + + Assert.Equal("mpt-app", plan.Items[0].TargetCliName); + Assert.Equal("fallback", plan.Items[1].TargetCliName); + } + + [Fact] + public void Unconfident_with_no_into_falls_back_to_a_generic_bucket() + { + var doc = new SnipStoreDocument(); + var plan = StoreMerger.Plan( + doc, + [new SnippetCandidate(string.Empty, false, new Snip { Title = "B", CommandTemplate = "{x} b" })], + _defaults); + + Assert.Equal("imported", plan.Items[0].TargetCliName); + } + } +} diff --git a/tools/Snipdeck.Importer/Commands/ImportCommandSettings.cs b/tools/Snipdeck.Importer/Commands/ImportCommandSettings.cs new file mode 100644 index 0000000..6281cc6 --- /dev/null +++ b/tools/Snipdeck.Importer/Commands/ImportCommandSettings.cs @@ -0,0 +1,37 @@ +using System.ComponentModel; + +using Spectre.Console.Cli; + +namespace Snipdeck.Importer.Commands +{ + /// + /// Options shared by every import subcommand. Defaults to a safe dry-run; only --write + /// touches the store. + /// + public class ImportCommandSettings : CommandSettings + { + [CommandArgument(0, "")] + [Description("Path to the source export to import.")] + public string Path { get; init; } = string.Empty; + + [CommandOption("--store ")] + [Description("Target Snipdeck store file. Defaults to the desktop app's store.")] + public string? Store { get; init; } + + [CommandOption("--write")] + [Description("Apply the changes. Without this flag the importer only previews (dry-run).")] + public bool Write { get; init; } + + [CommandOption("--cli ")] + [Description("Force every imported snip into this CLI, overriding auto-detection.")] + public string? Cli { get; init; } + + [CommandOption("--into ")] + [Description("Fallback CLI for snips whose CLI could not be confidently auto-detected.")] + public string? Into { get; init; } + + [CommandOption("--allow-duplicates")] + [Description("Import snips even if one with the same title and command already exists.")] + public bool AllowDuplicates { get; init; } + } +} diff --git a/tools/Snipdeck.Importer/Commands/SnipCommandImportCommand.cs b/tools/Snipdeck.Importer/Commands/SnipCommandImportCommand.cs new file mode 100644 index 0000000..e44dbea --- /dev/null +++ b/tools/Snipdeck.Importer/Commands/SnipCommandImportCommand.cs @@ -0,0 +1,105 @@ +using Snipdeck.Core.Services; +using Snipdeck.Importer.Merge; +using Snipdeck.Importer.Output; +using Snipdeck.Importer.Sources; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Snipdeck.Importer.Commands +{ + /// + /// snipdeck-importer snipcommand <path> — imports a SnipCommand export. + /// Previews by default; --write backs the store up, merges, and saves. + /// + public sealed class SnipCommandImportCommand : AsyncCommand + { + protected override async Task ExecuteAsync( + CommandContext context, + ImportCommandSettings settings, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(settings); + + var source = new SnipCommandSource(); + IReadOnlyList candidates; + try + { + candidates = source.Read(settings.Path); + } + catch (Exception ex) when (ex is FileNotFoundException or InvalidDataException or UnauthorizedAccessException or IOException) + { + AnsiConsole.MarkupLineInterpolated($"[red]error:[/] {ex.Message}"); + return 1; + } + + var targets = await ResolveTargetsAsync(settings.Store).ConfigureAwait(false); + + var store = new JsonSnipStore(targets.StorePath); + var document = await store.LoadAsync(cancellationToken).ConfigureAwait(false); + + var options = new MergeOptions(settings.Cli, settings.Into, settings.AllowDuplicates); + var plan = StoreMerger.Plan(document, candidates, options); + + DryRunRenderer.Render(source.DisplayName, targets.StorePath, plan, settings.Write); + + if (!settings.Write) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[grey]This was a dry-run. Re-run with [bold]--write[/] to apply.[/]"); + return 0; + } + + if (plan.ImportCount == 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("Nothing to import. The store was not modified."); + return 0; + } + + var backup = new BackupService(targets.StorePath, targets.BackupDirectory, new SystemClock(), targets.Retention); + var backupInfo = await backup.CreateBackupAsync(cancellationToken).ConfigureAwait(false); + + StoreMerger.Apply(document, plan); + await store.SaveAsync(document, cancellationToken).ConfigureAwait(false); + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLineInterpolated( + $"[green]Imported {plan.ImportCount} snip(s)[/] into {targets.StorePath}."); + if (backupInfo is not null) + { + AnsiConsole.MarkupLineInterpolated($"[grey]Backed up the previous store to {backupInfo.FilePath}[/]"); + } + + AnsiConsole.MarkupLine("[yellow]If Snipdeck is running, restart it to see the imported Snips.[/]"); + return 0; + } + + private static async Task ResolveTargetsAsync(string? explicitStore) + { + // An explicit --store keeps its backups beside it and uses the default retention, + // so importing into a throwaway store never touches the desktop app's backup folder. + if (!string.IsNullOrWhiteSpace(explicitStore)) + { + var storePath = Path.GetFullPath(explicitStore); + var directory = Path.GetDirectoryName(storePath) ?? Directory.GetCurrentDirectory(); + var backupDirectory = Path.Combine(directory, DefaultPaths.BackupsDirectoryName); + return new ImportTargets(storePath, backupDirectory, BackupService.DefaultRetention); + } + + // Otherwise mirror exactly what the desktop app resolves from its config. + var settingsStore = new JsonSettingsStore(DefaultPaths.SettingsFilePath); + var config = await settingsStore.LoadAsync().ConfigureAwait(false); + + // Clamp like the desktop app's lazy retention provider does — a corrupt config + // (e.g. BackupRetention = 0) must never crash the importer's fixed-retention BackupService. + var retention = Math.Max(1, config.BackupRetention); + return new ImportTargets( + DefaultPaths.ResolveStoreFilePath(config.StoragePath), + config.BackupDirectory ?? DefaultPaths.DefaultBackupDirectory, + retention); + } + + private sealed record ImportTargets(string StorePath, string BackupDirectory, int Retention); + } +} diff --git a/tools/Snipdeck.Importer/Merge/StoreMerger.cs b/tools/Snipdeck.Importer/Merge/StoreMerger.cs new file mode 100644 index 0000000..9aed541 --- /dev/null +++ b/tools/Snipdeck.Importer/Merge/StoreMerger.cs @@ -0,0 +1,145 @@ +using Snipdeck.Core.Models; +using Snipdeck.Importer.Sources; + +namespace Snipdeck.Importer.Merge +{ + /// + /// Plans and applies a merge of imported snippet candidates into an existing store document. + /// + /// is pure — it decides, per candidate, the target CLI and whether it is a + /// duplicate to skip, and lists the CLIs that would be created. mutates the + /// document: it creates missing CLIs, mints fresh identifiers, and appends the imported snips. + /// + /// + public static class StoreMerger + { + public static MergePlan Plan( + SnipStoreDocument target, + IReadOnlyList candidates, + MergeOptions options) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(candidates); + ArgumentNullException.ThrowIfNull(options); + + // De-duplication is scoped to the CLI a snip lands in: CLI is Snipdeck's organising + // axis, so the same (Title, CommandTemplate) under two different CLIs is legitimate. + var cliNameById = target.Clis.ToDictionary(c => c.Id, c => c.Name); + var existing = new HashSet<(string, string, string)>(); + foreach (var snip in target.Snips) + { + if (!snip.IsTrash) + { + var owningCli = cliNameById.TryGetValue(snip.CliId, out var name) ? name : string.Empty; + _ = existing.Add(DedupeKey(owningCli, snip.Title, snip.CommandTemplate)); + } + } + + var existingCliNames = new HashSet( + target.Clis.Select(c => c.Name), + StringComparer.OrdinalIgnoreCase); + + var items = new List(candidates.Count); + var clisToCreate = new List(); + var seenNewClis = new HashSet(StringComparer.OrdinalIgnoreCase); + var plannedKeys = new HashSet<(string, string, string)>(); + + foreach (var candidate in candidates) + { + var cliName = ResolveCliName(candidate, options); + var key = DedupeKey(cliName, candidate.Snip.Title, candidate.Snip.CommandTemplate); + + var isDuplicate = !options.AllowDuplicates + && (existing.Contains(key) || plannedKeys.Contains(key)); + + items.Add(new MergePlanItem(candidate, cliName, isDuplicate)); + + if (isDuplicate) + { + continue; + } + + _ = plannedKeys.Add(key); + + if (!existingCliNames.Contains(cliName) && seenNewClis.Add(cliName)) + { + clisToCreate.Add(cliName); + } + } + + return new MergePlan(items, clisToCreate); + } + + public static void Apply(SnipStoreDocument target, MergePlan plan) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(plan); + + // Case-insensitive lookup of CLI name -> Cli, seeded with what's already there. + var clisByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cli in target.Clis) + { + _ = clisByName.TryAdd(cli.Name, cli); + } + + foreach (var item in plan.Items) + { + if (item.IsDuplicateSkip) + { + continue; + } + + if (!clisByName.TryGetValue(item.TargetCliName, out var cli)) + { + cli = new Cli { Name = item.TargetCliName }; + target.Clis.Add(cli); + clisByName[item.TargetCliName] = cli; + } + + // The candidate already carries a fresh GUID (the source never reuses SnipCommand + // ids); just point it at the resolved CLI and append. + var snip = item.Candidate.Snip; + snip.CliId = cli.Id; + target.Snips.Add(snip); + } + } + + private static (string, string, string) DedupeKey(string cliName, string title, string commandTemplate) + { + // CLI name matches case-insensitively (Cli reuse is case-insensitive); title/command are exact. + return (cliName.ToLowerInvariant(), title, commandTemplate); + } + + private static string ResolveCliName(SnippetCandidate candidate, MergeOptions options) + { + if (!string.IsNullOrWhiteSpace(options.ForceCli)) + { + return options.ForceCli.Trim(); + } + + var confidentName = candidate.CliConfident && !string.IsNullOrWhiteSpace(candidate.SuggestedCliName) + ? candidate.SuggestedCliName + : null; + var into = string.IsNullOrWhiteSpace(options.Into) ? null : options.Into.Trim(); + var bestEffort = string.IsNullOrWhiteSpace(candidate.SuggestedCliName) ? null : candidate.SuggestedCliName; + + // Precedence: a confident auto-suggestion, then the --into fallback, then any suggestion, + // and finally a generic bucket so nothing is ever left without a home. + return confidentName ?? into ?? bestEffort ?? "imported"; + } + } + + /// Knobs that shape a merge, mapped from the CLI options. + public sealed record MergeOptions(string? ForceCli, string? Into, bool AllowDuplicates); + + /// One candidate's planned outcome. + public sealed record MergePlanItem(SnippetCandidate Candidate, string TargetCliName, bool IsDuplicateSkip); + + /// The full plan: per-candidate decisions and the CLIs that would be created. + public sealed record MergePlan(IReadOnlyList Items, IReadOnlyList ClisToCreate) + { + public int ImportCount => Items.Count(i => !i.IsDuplicateSkip); + + public int SkipCount => Items.Count(i => i.IsDuplicateSkip); + } +} diff --git a/tools/Snipdeck.Importer/Output/DryRunRenderer.cs b/tools/Snipdeck.Importer/Output/DryRunRenderer.cs new file mode 100644 index 0000000..b3c365b --- /dev/null +++ b/tools/Snipdeck.Importer/Output/DryRunRenderer.cs @@ -0,0 +1,77 @@ +using Snipdeck.Importer.Merge; + +using Spectre.Console; + +namespace Snipdeck.Importer.Output +{ + /// + /// Renders a merge plan as Spectre tables grouped by target CLI. All cell content comes from + /// untrusted input, so every value is escaped before rendering. + /// + public static class DryRunRenderer + { + public static void Render(string sourceName, string storePath, MergePlan plan, bool willWrite) + { + var mode = willWrite ? "[yellow]WRITE[/]" : "[green]DRY-RUN[/]"; + AnsiConsole.MarkupLineInterpolated( + $"Importing from {sourceName} into {storePath}"); + AnsiConsole.MarkupLine($"Mode: {mode}"); + AnsiConsole.WriteLine(); + + var byCli = plan.Items + .GroupBy(i => i.TargetCliName, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase); + + var newClis = new HashSet(plan.ClisToCreate, StringComparer.OrdinalIgnoreCase); + + foreach (var group in byCli) + { + var heading = newClis.Contains(group.Key) + ? $"{Escape(group.Key)} [grey](new CLI)[/]" + : Escape(group.Key); + + var table = new Table() + .Title(heading) + .Border(TableBorder.Rounded) + .AddColumn("Title") + .AddColumn("Command template") + .AddColumn("Params") + .AddColumn("Status"); + + foreach (var item in group) + { + var status = item.IsDuplicateSkip + ? "[grey]skip (duplicate)[/]" + : "[green]import[/]"; + var lowConfidence = !item.Candidate.CliConfident && !item.IsDuplicateSkip + ? " [yellow](CLI guessed)[/]" + : string.Empty; + + _ = table.AddRow( + Escape(item.Candidate.Snip.Title), + Escape(Truncate(item.Candidate.Snip.CommandTemplate, 70)), + item.Candidate.Snip.Parameters.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), + status + lowConfidence); + } + + AnsiConsole.Write(table); + } + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLineInterpolated( + $"CLIs to create: {plan.ClisToCreate.Count} Snips to import: {plan.ImportCount} Skipped (duplicates): {plan.SkipCount}"); + } + + private static string Escape(string value) + { + return Markup.Escape(value ?? string.Empty); + } + + private static string Truncate(string value, int max) + { + return string.IsNullOrEmpty(value) || value.Length <= max + ? value ?? string.Empty + : value[..(max - 1)] + "…"; + } + } +} diff --git a/tools/Snipdeck.Importer/Program.cs b/tools/Snipdeck.Importer/Program.cs new file mode 100644 index 0000000..3c00dce --- /dev/null +++ b/tools/Snipdeck.Importer/Program.cs @@ -0,0 +1,17 @@ +using Snipdeck.Importer.Commands; + +using Spectre.Console.Cli; + +var app = new CommandApp(); +app.Configure(config => +{ + _ = config.SetApplicationName("snipdeck-importer"); + + _ = config.AddCommand("snipcommand") + .WithDescription("Import command snippets from a SnipCommand export.") + .WithExample("snipcommand", "snipcommand.db") + .WithExample("snipcommand", "snipcommand.db", "--write") + .WithExample("snipcommand", "snipcommand.db", "--store", "store.json", "--cli", "mpt-app"); +}); + +return await app.RunAsync(args); diff --git a/tools/Snipdeck.Importer/README.md b/tools/Snipdeck.Importer/README.md new file mode 100644 index 0000000..c21ff1d --- /dev/null +++ b/tools/Snipdeck.Importer/README.md @@ -0,0 +1,82 @@ +# snipdeck-importer + +A small cross-platform console tool that imports command snippets from other tools +into a [Snipdeck](../../README.md) store. The first supported source is +**SnipCommand**; the design leaves room for more sources as subcommands later. + +It is shipped and versioned independently of the desktop app (it is a .NET tool), +and targets `net10.0`, so it builds and runs on Windows, Linux and macOS — handy +for bulk-prepping an import on a non-Windows box before opening Snipdeck. + +## Install + +```bash +dotnet tool install -g Snipdeck.Importer +``` + +Or run it straight from the repo without installing: + +```bash +dotnet run --project tools/Snipdeck.Importer -- snipcommand +``` + +## Usage + +``` +snipdeck-importer snipcommand [options] +``` + +`` is a SnipCommand export. Despite SnipCommand's `.db` extension, the export +is JSON — point the importer straight at it. + +| Option | Default | Description | +| --- | --- | --- | +| `--store ` | the desktop app's store | Target Snipdeck store file. | +| `--write` | off | Apply the changes. Without it, the importer only previews (dry-run). | +| `--cli ` | — | Force every imported snip into this CLI, overriding auto-detection. | +| `--into ` | — | Fallback CLI for snips whose CLI could not be confidently auto-detected. | +| `--allow-duplicates` | off | Import snips even if one with the same title and command already exists. | + +### Dry-run first + +By default the importer parses the source, prints the planned additions grouped by +the CLI each snip would land in, and exits **without touching the store**: + +```bash +snipdeck-importer snipcommand snipcommand.db +``` + +Add `--write` once the preview looks right: + +```bash +snipdeck-importer snipcommand snipcommand.db --write +``` + +## What it does + +- **Groups by CLI.** SnipCommand stores a flat list; Snipdeck organises snips under + a CLI. The CLI is auto-suggested from the first whitespace-delimited token of each + command (`mpt-app orders list` → `mpt-app`), peeling off launcher wrappers such as + `sudo` / `npx`. Use `--cli` to force one CLI for everything, or `--into` as a + fallback for commands the importer couldn't classify confidently. +- **Translates parameters.** SnipCommand's inline `[sc_choice …]` and + `[sc_variable …]` markup becomes Snipdeck `{token}` placeholders plus a structured + parameter list (Choice with options, or Text), with a sensible default carried + across. Variable names containing spaces or punctuation are slugified into legal + token names. +- **Carries metadata.** Title, description, tags and favourite flag come across; + usage counts and last-used timestamps are preserved when present. Trashed entries + are skipped. +- **Merges safely.** With `--write` the importer backs the existing store up first + (honouring the desktop app's retention policy), mints fresh identifiers, creates + any missing CLIs on demand, and skips snips that already exist by + `(title, command)` unless `--allow-duplicates` is given. The atomic store write + means a running desktop instance can't be corrupted — but it won't *see* the + changes until it next reloads, so **restart Snipdeck after importing**. + +## Building and testing + +```bash +dotnet build tools/Snipdeck.Importer +dotnet test tools/Snipdeck.Importer.Tests +``` diff --git a/tools/Snipdeck.Importer/Snipdeck.Importer.csproj b/tools/Snipdeck.Importer/Snipdeck.Importer.csproj new file mode 100644 index 0000000..8f84bf2 --- /dev/null +++ b/tools/Snipdeck.Importer/Snipdeck.Importer.csproj @@ -0,0 +1,31 @@ + + + + Exe + net10.0 + Snipdeck.Importer + + + true + snipdeck-importer + Snipdeck.Importer + Imports command snippets from SnipCommand (and other sources) into a Snipdeck store. + + + $(NoWarn);CS1591;CA1305 + + + + + + + + + + + + diff --git a/tools/Snipdeck.Importer/Sources/ISnippetSource.cs b/tools/Snipdeck.Importer/Sources/ISnippetSource.cs new file mode 100644 index 0000000..c921fb5 --- /dev/null +++ b/tools/Snipdeck.Importer/Sources/ISnippetSource.cs @@ -0,0 +1,24 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Importer.Sources +{ + /// + /// Reads snippet candidates from a source export. Each adapter (SnipCommand today, others later) + /// isolates all format-specific parsing behind this interface and yields format-agnostic + /// s for the merger to consume. + /// + public interface ISnippetSource + { + /// The human-friendly source name, used in messages (e.g. "SnipCommand"). + string DisplayName { get; } + + /// Reads the file at and returns the candidates it contains. + IReadOnlyList Read(string path); + } + + /// + /// A snip ready to import, plus the source's suggestion for which CLI it belongs to and whether + /// that suggestion is trustworthy. The merger resolves the final CLI from this and the CLI options. + /// + public sealed record SnippetCandidate(string SuggestedCliName, bool CliConfident, Snip Snip); +} diff --git a/tools/Snipdeck.Importer/Sources/SnipCommandJsonModels.cs b/tools/Snipdeck.Importer/Sources/SnipCommandJsonModels.cs new file mode 100644 index 0000000..62471ea --- /dev/null +++ b/tools/Snipdeck.Importer/Sources/SnipCommandJsonModels.cs @@ -0,0 +1,33 @@ +namespace Snipdeck.Importer.Sources +{ + /// + /// Mirrors a SnipCommand export document. Despite the .db extension SnipCommand uses, + /// the export is pretty-printed JSON of the shape { "commands": [ … ] }. + /// Deserialised case-insensitively, so the camelCase JSON keys map onto these members. + /// + internal sealed class SnipCommandExport + { + public List Commands { get; set; } = []; + } + + /// One SnipCommand entry. The opaque id is intentionally not modelled — it is discarded. + internal sealed class SnipCommandEntry + { + public string? Title { get; set; } + + public string? Command { get; set; } + + /// Comma-separated tag list (a single string, not an array). + public string? Tags { get; set; } + + public string? Description { get; set; } + + public bool IsFavourite { get; set; } + + public bool IsTrash { get; set; } + + public int? UsageCount { get; set; } + + public DateTimeOffset? LastUsedAt { get; set; } + } +} diff --git a/tools/Snipdeck.Importer/Sources/SnipCommandSource.cs b/tools/Snipdeck.Importer/Sources/SnipCommandSource.cs new file mode 100644 index 0000000..091fed2 --- /dev/null +++ b/tools/Snipdeck.Importer/Sources/SnipCommandSource.cs @@ -0,0 +1,97 @@ +using System.Text.Json; + +using Snipdeck.Core.Engine; +using Snipdeck.Core.Models; +using Snipdeck.Importer.Translation; + +namespace Snipdeck.Importer.Sources +{ + /// + /// Reads a SnipCommand JSON export and yields import candidates, translating inline + /// sc_* markup into structured parameters and suggesting a CLI per command. + /// All SnipCommand-specific knowledge lives here. + /// + public sealed class SnipCommandSource : ISnippetSource + { + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + }; + + public string DisplayName => "SnipCommand"; + + public IReadOnlyList Read(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!File.Exists(path)) + { + throw new FileNotFoundException($"SnipCommand export not found: {path}", path); + } + + var text = File.ReadAllText(path); + return ReadFromText(text); + } + + /// Parses export content already loaded into memory. Exposed for testing. + public static IReadOnlyList ReadFromText(string text) + { + // Sniff the format: SnipCommand exports are a JSON object, regardless of the .db extension. + var trimmed = text.AsSpan().TrimStart(); + if (trimmed.IsEmpty || trimmed[0] != '{') + { + throw new InvalidDataException( + "The file does not look like a SnipCommand JSON export (expected a JSON object starting with '{')."); + } + + SnipCommandExport? export; + try + { + export = JsonSerializer.Deserialize(text, _jsonOptions); + } + catch (JsonException ex) + { + throw new InvalidDataException($"The SnipCommand export could not be parsed: {ex.Message}", ex); + } + + if (export?.Commands is not { Count: > 0 } commands) + { + return []; + } + + var candidates = new List(commands.Count); + foreach (var entry in commands) + { + if (entry is null || entry.IsTrash) + { + continue; + } + + candidates.Add(ToCandidate(entry)); + } + + return candidates; + } + + private static SnippetCandidate ToCandidate(SnipCommandEntry entry) + { + var translation = ScMarkupTranslator.Translate(entry.Command); + var suggestion = CliSuggester.Suggest(translation.CommandTemplate); + + var snip = new Snip + { + Title = (entry.Title ?? string.Empty).Trim(), + CommandTemplate = translation.CommandTemplate, + Description = string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description.Trim(), + Tags = TagParser.Parse(entry.Tags), + Parameters = [.. translation.Parameters], + IsFavourite = entry.IsFavourite, + UsageCount = entry.UsageCount is > 0 ? entry.UsageCount.Value : 0, + LastUsedAt = entry.LastUsedAt, + }; + + return new SnippetCandidate(suggestion.Name, suggestion.Confident, snip); + } + } +} diff --git a/tools/Snipdeck.Importer/Translation/CliSuggester.cs b/tools/Snipdeck.Importer/Translation/CliSuggester.cs new file mode 100644 index 0000000..d25d46c --- /dev/null +++ b/tools/Snipdeck.Importer/Translation/CliSuggester.cs @@ -0,0 +1,61 @@ +namespace Snipdeck.Importer.Translation +{ + /// + /// Suggests the owning CLI for a command by taking its first whitespace-delimited token, + /// peeling off well-known process wrappers (sudo, npx, …) so the suggestion is + /// the real tool rather than the launcher. + /// + /// Language runtimes such as pip, python and dotnet are deliberately + /// not peeled: in practice those are the CLI the user organises around. + /// + /// + public static class CliSuggester + { + // Pure launchers: the interesting command is whatever they invoke. + private static readonly HashSet _wrappers = new(StringComparer.OrdinalIgnoreCase) + { + "sudo", "doas", "env", "npx", "pnpx", "bunx", "time", "nice", "nohup", "command", "exec", + }; + + public static CliSuggestion Suggest(string? commandTemplate) + { + if (string.IsNullOrWhiteSpace(commandTemplate)) + { + return new CliSuggestion(string.Empty, Confident: false); + } + + var tokens = commandTemplate.Split( + (char[]?)null, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var index = 0; + + // Peel any leading wrappers, plus their leading -flags (e.g. `env FOO=bar tool`). + while (index < tokens.Length && IsWrapperOrFlag(tokens[index])) + { + index++; + } + + if (index >= tokens.Length) + { + return new CliSuggestion(string.Empty, Confident: false); + } + + var candidate = tokens[index]; + + // A leading placeholder (the command itself is parameterised) can't be a confident name. + return candidate.Contains('{') + ? new CliSuggestion(string.Empty, Confident: false) + : new CliSuggestion(candidate, Confident: true); + } + + private static bool IsWrapperOrFlag(string token) + { + // Option flags and env-style assignments (FOO=bar) belong to the wrapper, not the tool. + return token.StartsWith('-') || token.Contains('=') || _wrappers.Contains(token); + } + } + + /// A suggested CLI name and whether the suggestion is trustworthy. + public sealed record CliSuggestion(string Name, bool Confident); +} diff --git a/tools/Snipdeck.Importer/Translation/ScMarkupTranslator.cs b/tools/Snipdeck.Importer/Translation/ScMarkupTranslator.cs new file mode 100644 index 0000000..65848fb --- /dev/null +++ b/tools/Snipdeck.Importer/Translation/ScMarkupTranslator.cs @@ -0,0 +1,218 @@ +using System.Text; +using System.Text.RegularExpressions; + +using Snipdeck.Core.Models; + +namespace Snipdeck.Importer.Translation +{ + /// + /// Translates SnipCommand inline markup into Snipdeck's {token} placeholders plus a + /// structured list. + /// + /// Two self-closing forms are recognised: + /// [sc_choice name="x" value="a,b,c" /] (CSV options, first is the default) and + /// [sc_variable name="x" value="default" /] (an empty value means no default). + /// + /// + /// The translator is pure and defensive: input is treated as untrusted, names are slugified + /// into legal token identifiers (with collisions disambiguated), control/escape characters are + /// stripped from values, lengths are capped, and any markup it cannot parse is left verbatim so + /// the command never gets corrupted. + /// + /// + public static partial class ScMarkupTranslator + { + private const int _maxNameLength = 100; + private const int _maxValueLength = 4000; + private const int _maxOptions = 100; + + // A self-closing [sc_choice ...] / [sc_variable ...] tag. Attribute values are + // double-quoted and may contain anything except a quote (so backslashes, spaces, + // commas and ']' inside a value are fine). Non-self-closing or malformed tags simply + // don't match and are left untouched. + [GeneratedRegex( + """\[sc_(?choice|variable)(?(?:\s+[A-Za-z][A-Za-z0-9]*\s*=\s*"[^"]*")*)\s*/\]""", + RegexOptions.CultureInvariant)] + private static partial Regex TagRegex(); + + [GeneratedRegex( + "(?[A-Za-z][A-Za-z0-9]*)\\s*=\\s*\"(?[^\"]*)\"", + RegexOptions.CultureInvariant)] + private static partial Regex AttrRegex(); + + public static ScTranslation Translate(string? command) + { + if (string.IsNullOrEmpty(command)) + { + return new ScTranslation(command ?? string.Empty, []); + } + + // name (as written in the markup) -> minted token; lets a name repeated within one + // command map to a single token and a single Parameter. + var nameToToken = new Dictionary(StringComparer.Ordinal); + var usedTokens = new HashSet(StringComparer.Ordinal); + var parameters = new List(); + + var rewritten = TagRegex().Replace(command, match => + { + var attributes = ParseAttributes(match.Groups["attrs"].Value); + if (!attributes.TryGetValue("name", out var rawName) || string.IsNullOrWhiteSpace(rawName)) + { + // No usable name — leave the markup verbatim rather than guess. + return match.Value; + } + + var isChoice = string.Equals(match.Groups["kind"].Value, "choice", StringComparison.Ordinal); + _ = attributes.TryGetValue("value", out var rawValue); + rawValue ??= string.Empty; + + if (!nameToToken.TryGetValue(rawName, out var token)) + { + token = MintToken(rawName, usedTokens); + nameToToken[rawName] = token; + _ = usedTokens.Add(token); + parameters.Add(BuildParameter(token, isChoice, rawValue)); + } + + return "{" + token + "}"; + }); + + return new ScTranslation(rewritten, parameters); + } + + private static Parameter BuildParameter(string token, bool isChoice, string rawValue) + { + var value = Sanitise(rawValue); + if (isChoice) + { + var options = value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Take(_maxOptions) + .ToList(); + return new Parameter + { + Name = token, + Type = ParameterType.Choice, + Options = options, + Default = options.Count > 0 ? options[0] : null, + }; + } + + return new Parameter + { + Name = token, + Type = ParameterType.Text, + Default = value.Length == 0 ? null : value, + }; + } + + private static Dictionary ParseAttributes(string attrs) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Match attr in AttrRegex().Matches(attrs)) + { + // First occurrence wins; ignore duplicate keys. + _ = result.TryAdd(attr.Groups["key"].Value, attr.Groups["val"].Value); + } + + return result; + } + + /// + /// Turns a markup name into a legal Snipdeck token matching [A-Za-z_][A-Za-z0-9_]*, + /// disambiguating against tokens already used in the same command. + /// + private static string MintToken(string rawName, HashSet usedTokens) + { + var slug = Slugify(rawName); + if (!usedTokens.Contains(slug)) + { + return slug; + } + + for (var suffix = 2; ; suffix++) + { + var candidate = slug + "_" + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (!usedTokens.Contains(candidate)) + { + return candidate; + } + } + } + + private static string Slugify(string rawName) + { + var name = Sanitise(rawName); + if (name.Length > _maxNameLength) + { + name = name[.._maxNameLength]; + } + + // Already a legal identifier? Keep it as-is so "authId" / "customerId" survive verbatim. + if (IdentifierRegex().IsMatch(name)) + { + return name; + } + + // Otherwise build PascalCase-ish from alphanumeric runs: "Agreement ID" -> "AgreementID", + // "Order ID / Agreement ID" -> "OrderIDAgreementID". + var parts = AlphanumericRunRegex().Matches(name); + if (parts.Count == 0) + { + return "param"; + } + + var builder = new StringBuilder(); + for (var i = 0; i < parts.Count; i++) + { + var part = parts[i].Value; + if (i == 0) + { + _ = builder.Append(part); + } + else + { + _ = builder.Append(char.ToUpperInvariant(part[0])); + if (part.Length > 1) + { + _ = builder.Append(part[1..]); + } + } + } + + // parts is non-empty here and every run contributes at least one character, so the + // slug is never empty. A leading digit is illegal for a token; prefix an underscore. + var slug = builder.ToString(); + return char.IsDigit(slug[0]) ? "_" + slug : slug; + } + + /// Strips control and ANSI escape characters and caps length. + private static string Sanitise(string value) + { + var builder = new StringBuilder(value.Length); + foreach (var ch in value) + { + if (!char.IsControl(ch)) + { + _ = builder.Append(ch); + } + + if (builder.Length >= _maxValueLength) + { + break; + } + } + + return builder.ToString().Trim(); + } + + [GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.CultureInvariant)] + private static partial Regex IdentifierRegex(); + + [GeneratedRegex("[A-Za-z0-9]+", RegexOptions.CultureInvariant)] + private static partial Regex AlphanumericRunRegex(); + } + + /// The rewritten command template plus the parameters extracted from its markup. + public sealed record ScTranslation(string CommandTemplate, IReadOnlyList Parameters); +}