Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
<PackageVersion Include="Velopack" Version="1.0.1" />
<PackageVersion Include="Velopack" Version="1.1.1" />
</ItemGroup>

<!-- Importer tool (console) -->
<ItemGroup>
<PackageVersion Include="Spectre.Console" Version="0.55.0" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.55.0" />
</ItemGroup>

<!-- Tests -->
Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions Snipdeck.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@
</Project>
<Project Path="src/Snipdeck.Core/Snipdeck.Core.csproj" />
<Project Path="tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj" />
<Project Path="tools/Snipdeck.Importer/Snipdeck.Importer.csproj" />
<Project Path="tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj" />
</Solution>
30 changes: 8 additions & 22 deletions src/Snipdeck.App/Services/WindowsPathProvider.cs
Original file line number Diff line number Diff line change
@@ -1,37 +1,23 @@
using Snipdeck.Core.Abstractions;
using Snipdeck.Core.Services;

namespace Snipdeck.App.Services
{
/// <summary>
/// Resolves Snipdeck's data paths under <c>%LOCALAPPDATA%\Snipdeck</c>.
/// The layout lives in <see cref="DefaultPaths"/> (Core) so the cross-platform
/// importer tool resolves identical locations; this provider simply delegates.
/// </summary>
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;
}
}
2 changes: 1 addition & 1 deletion src/Snipdeck.App/Services/WindowsShellInteractions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public async Task NotifyAsync(string title, string message, string buttonText =
public async Task<SnipEditResult?> EditSnipAsync(Snip snip, IReadOnlyList<Cli> availableClis)
{
ArgumentNullException.ThrowIfNull(snip);
var editor = new SnipEditorViewModel(snip);
var editor = new SnipEditorViewModel(snip, availableClis);
var dialog = new SnipEditorDialog(editor)
{
XamlRoot = GetXamlRoot(),
Expand Down
12 changes: 12 additions & 0 deletions src/Snipdeck.App/Views/SnipEditorDialog.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@
<TextBox Header="Title"
Text="{x:Bind ViewModel.Title, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

<!-- The CLI the snip belongs to. Changing it moves the snip to another CLI. -->
<ComboBox Header="CLI"
HorizontalAlignment="Stretch"
ItemsSource="{x:Bind ViewModel.AvailableClis}"
SelectedItem="{x:Bind ViewModel.SelectedCli, Mode=TwoWay}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:Cli">
<TextBlock Text="{x:Bind Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>

<TextBox Text="{x:Bind ViewModel.CommandTemplate, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
FontFamily="Cascadia Mono, Consolas, Courier New"
AcceptsReturn="True"
Expand Down
19 changes: 19 additions & 0 deletions src/Snipdeck.Core/Engine/TagParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Snipdeck.Core.Engine
{
/// <summary>
/// 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.
/// </summary>
public static class TagParser
{
public static List<string> Parse(string? text)
{
return string.IsNullOrWhiteSpace(text)
? []
: [.. text
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)];
}
}
}
50 changes: 50 additions & 0 deletions src/Snipdeck.Core/Services/DefaultPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
namespace Snipdeck.Core.Services
{
/// <summary>
/// Resolves Snipdeck's default data paths under <c>%LOCALAPPDATA%\Snipdeck</c> (Windows)
/// or the platform equivalent of <see cref="Environment.SpecialFolder.LocalApplicationData"/>.
/// <para>
/// 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
/// <c>WindowsPathProvider</c> delegates here, so its behaviour is unchanged.
/// </para>
/// </summary>
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";

/// <summary>The Snipdeck data root, e.g. <c>%LOCALAPPDATA%\Snipdeck</c>.</summary>
public static string AppDataDirectory { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
AppFolderName);

/// <summary>The app-config file, stored separately from the snip store.</summary>
public static string SettingsFilePath { get; } = Path.Combine(AppDataDirectory, SettingsFileName);

/// <summary>The default directory that holds the snip store document.</summary>
public static string DefaultStorageDirectory { get; } = Path.Combine(AppDataDirectory, StoreDirectoryName);

/// <summary>The default directory that holds timestamped store backups.</summary>
public static string DefaultBackupDirectory { get; } = Path.Combine(AppDataDirectory, BackupsDirectoryName);

/// <summary>The default directory that holds application logs.</summary>
public static string LogsDirectory { get; } = Path.Combine(AppDataDirectory, LogsDirectoryName);

/// <summary>
/// Composes the full default store-document path (<c>&lt;storage&gt;/store.json</c>) from an
/// optional configured storage directory, falling back to <see cref="DefaultStorageDirectory"/>.
/// </summary>
public static string ResolveStoreFilePath(string? configuredStorageDirectory)
{
var storageDirectory = string.IsNullOrWhiteSpace(configuredStorageDirectory)
? DefaultStorageDirectory
: configuredStorageDirectory;
return Path.Combine(storageDirectory, StoreFileName);
}
}
}
90 changes: 87 additions & 3 deletions src/Snipdeck.Core/Services/ExamplesSeed.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading