diff --git a/CHANGELOG.md b/CHANGELOG.md index c1642f5..d1ed379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 each tag that opens a searchable grid of icons to pick from, so you no longer have to know a Segoe Fluent Icons code point. Search filters by name, keyword or code point, and the free-text field remains for pasting a glyph directly. The - catalogue is a curated set in `appsettings.json` beside the app — edit that file - to add or remove icons, and the picker reflects the change the next time it opens. + catalogue ships with 100 curated icons and is a plain JSON file + (`icon-catalogue.json`) in the Snipdeck app-data folder — edit it to add or + remove icons (the picker reflects the change the next time it opens), and your + edits survive app updates. Delete it to restore the default set. ### Changed - **Home, navigation and shared-parameters polish.** The Home page leads with a diff --git a/src/Snipdeck.App/Assets/icon-catalogue.json b/src/Snipdeck.App/Assets/icon-catalogue.json new file mode 100644 index 0000000..a4f3a57 --- /dev/null +++ b/src/Snipdeck.App/Assets/icon-catalogue.json @@ -0,0 +1,105 @@ +{ + "$comment": "Default icon catalogue for the icon picker. Each entry is a Segoe Fluent Icons glyph: 'code' is the hex code point, 'name' is shown and searched, 'keywords' add extra search terms. This file is the bundled default; on first run it is copied to %LOCALAPPDATA%\\Snipdeck\\icon-catalogue.json, which is what the app actually reads. Edit that copy to add or remove glyphs — the picker re-reads it each time it opens, and your edits survive app updates. Delete the copy to restore this default. The free-text icon field remains available for anything not listed here.", + "glyphCatalogue": [ + { "code": "E80F", "name": "Home", "keywords": [ "house", "dashboard", "start" ] }, + { "code": "E713", "name": "Settings", "keywords": [ "gear", "cog", "config", "preferences", "options" ] }, + { "code": "E74E", "name": "Save", "keywords": [ "disk", "store", "floppy" ] }, + { "code": "E74D", "name": "Delete", "keywords": [ "trash", "bin", "remove", "discard" ] }, + { "code": "E70F", "name": "Edit", "keywords": [ "pencil", "modify", "change", "write" ] }, + { "code": "E710", "name": "Add", "keywords": [ "plus", "new", "create" ] }, + { "code": "E711", "name": "Cancel", "keywords": [ "close", "cross", "dismiss", "remove" ] }, + { "code": "E8FB", "name": "Accept", "keywords": [ "check", "tick", "done", "confirm", "ok" ] }, + { "code": "E930", "name": "Completed", "keywords": [ "check circle", "success", "passed", "approved" ] }, + { "code": "E721", "name": "Search", "keywords": [ "find", "magnify", "lookup", "query" ] }, + { "code": "E72C", "name": "Refresh", "keywords": [ "reload", "sync", "update", "retry" ] }, + { "code": "E895", "name": "Sync", "keywords": [ "synchronise", "refresh", "two way", "update" ] }, + { "code": "E8EE", "name": "Renewal", "keywords": [ "renew", "recurring", "subscription", "repeat", "auto renew" ] }, + { "code": "E81C", "name": "History", "keywords": [ "recent", "past", "log", "timeline", "audit" ] }, + { "code": "E8B7", "name": "Folder", "keywords": [ "directory", "files", "path" ] }, + { "code": "E8E5", "name": "Open file", "keywords": [ "open", "document", "load" ] }, + { "code": "E8DA", "name": "Open folder", "keywords": [ "browse", "local", "directory" ] }, + { "code": "E8A5", "name": "Document", "keywords": [ "file", "page", "text", "doc" ] }, + { "code": "E8C8", "name": "Copy", "keywords": [ "duplicate", "clipboard", "clone" ] }, + { "code": "E8C6", "name": "Cut", "keywords": [ "scissors", "clipboard", "move" ] }, + { "code": "E77F", "name": "Paste", "keywords": [ "clipboard", "insert" ] }, + { "code": "E715", "name": "Mail", "keywords": [ "email", "envelope", "message", "inbox" ] }, + { "code": "E724", "name": "Send", "keywords": [ "submit", "forward", "deliver" ] }, + { "code": "E8BD", "name": "Message", "keywords": [ "chat", "comment", "speech", "reply" ] }, + { "code": "E72D", "name": "Share", "keywords": [ "distribute", "export", "send" ] }, + { "code": "E72A", "name": "Forward", "keywords": [ "next", "right", "advance" ] }, + { "code": "E72B", "name": "Back", "keywords": [ "previous", "left", "return" ] }, + { "code": "E8B5", "name": "Import", "keywords": [ "ingest", "load", "bring in" ] }, + { "code": "E8AB", "name": "Transfer", "keywords": [ "move", "swap", "migrate", "switch", "exchange" ] }, + { "code": "E774", "name": "Globe", "keywords": [ "world", "web", "internet", "network", "online" ] }, + { "code": "E753", "name": "Cloud", "keywords": [ "remote", "storage", "sky", "hosted" ] }, + { "code": "E896", "name": "Download", "keywords": [ "pull", "save", "fetch", "arrow down" ] }, + { "code": "E898", "name": "Upload", "keywords": [ "push", "publish", "arrow up" ] }, + { "code": "E74A", "name": "Move up", "keywords": [ "promote", "top", "arrow up", "raise" ] }, + { "code": "E7B8", "name": "Package", "keywords": [ "box", "parcel", "shipment", "bundle", "release" ] }, + { "code": "E768", "name": "Play", "keywords": [ "run", "start", "execute", "go" ] }, + { "code": "E769", "name": "Pause", "keywords": [ "hold", "suspend", "wait" ] }, + { "code": "E71A", "name": "Stop", "keywords": [ "halt", "end", "kill", "cancel" ] }, + { "code": "E787", "name": "Calendar", "keywords": [ "date", "schedule", "event", "day" ] }, + { "code": "EC92", "name": "Date and time", "keywords": [ "datetime", "schedule", "timestamp" ] }, + { "code": "E917", "name": "Clock", "keywords": [ "time", "recent", "timer", "duration" ] }, + { "code": "E72E", "name": "Lock", "keywords": [ "secure", "private", "locked", "protected" ] }, + { "code": "E785", "name": "Unlock", "keywords": [ "open", "unsecure", "unlocked" ] }, + { "code": "EA18", "name": "Shield", "keywords": [ "security", "protect", "safe", "defend", "guard" ] }, + { "code": "E77B", "name": "Person", "keywords": [ "contact", "user", "individual", "account", "profile" ] }, + { "code": "E779", "name": "Customer", "keywords": [ "client", "contact", "account", "buyer", "tenant" ] }, + { "code": "E716", "name": "People", "keywords": [ "users", "team", "members", "audience" ] }, + { "code": "E902", "name": "Group", "keywords": [ "team", "people", "members", "organisation unit" ] }, + { "code": "E748", "name": "Switch account", "keywords": [ "switch user", "change account", "impersonate", "tenant" ] }, + { "code": "EC06", "name": "Organisation", "keywords": [ "company", "building", "city", "tenant", "enterprise" ] }, + { "code": "E821", "name": "Work", "keywords": [ "briefcase", "job", "business", "portfolio" ] }, + { "code": "E8C7", "name": "Billing", "keywords": [ "payment", "credit card", "charge", "pay", "card" ] }, + { "code": "E9F9", "name": "Invoice", "keywords": [ "bill", "statement", "receipt", "charges", "document" ] }, + { "code": "E8EF", "name": "Calculator", "keywords": [ "calculate", "maths", "sum", "total" ] }, + { "code": "E94C", "name": "Discount", "keywords": [ "percentage", "percent", "rate", "tax", "margin" ] }, + { "code": "EB95", "name": "Contract", "keywords": [ "agreement", "certificate", "terms", "signed", "licence" ] }, + { "code": "E719", "name": "Marketplace", "keywords": [ "shop", "store", "catalogue", "storefront", "buy" ] }, + { "code": "E7BF", "name": "Orders", "keywords": [ "cart", "shopping", "purchase", "basket", "checkout" ] }, + { "code": "E8FD", "name": "Price list", "keywords": [ "rate card", "pricing", "list", "catalogue", "tariff" ] }, + { "code": "E8EC", "name": "Tag", "keywords": [ "label", "category", "default" ] }, + { "code": "E718", "name": "Pin", "keywords": [ "pushpin", "attach", "stick" ] }, + { "code": "E723", "name": "Attach", "keywords": [ "paperclip", "attachment", "file" ] }, + { "code": "E71B", "name": "Link", "keywords": [ "url", "chain", "hyperlink", "connect" ] }, + { "code": "E734", "name": "Favourite", "keywords": [ "star", "favorite", "bookmark", "important" ] }, + { "code": "E8A4", "name": "Bookmark", "keywords": [ "save", "ribbon", "mark", "saved" ] }, + { "code": "EB51", "name": "Heart", "keywords": [ "like", "love", "wishlist" ] }, + { "code": "E7C1", "name": "Flag", "keywords": [ "mark", "report", "milestone" ] }, + { "code": "E71C", "name": "Filter", "keywords": [ "funnel", "refine", "narrow" ] }, + { "code": "E8CB", "name": "Sort", "keywords": [ "order", "arrange", "rank" ] }, + { "code": "E8D7", "name": "Permissions", "keywords": [ "access", "roles", "rights", "key", "security", "scope" ] }, + { "code": "E9D5", "name": "Checklist", "keywords": [ "tasks", "todo", "checkboxes", "steps" ] }, + { "code": "E71D", "name": "Apps", "keywords": [ "all apps", "grid", "applications", "tiles" ] }, + { "code": "E890", "name": "View", "keywords": [ "show", "see", "display", "look" ] }, + { "code": "E8FF", "name": "Preview", "keywords": [ "view", "look", "inspect", "peek" ] }, + { "code": "E8A9", "name": "View all", "keywords": [ "list view", "show all", "details" ] }, + { "code": "ED1A", "name": "Hide", "keywords": [ "hidden", "invisible", "conceal", "off" ] }, + { "code": "E9D2", "name": "Report", "keywords": [ "chart", "analytics", "graph", "metrics", "area chart", "dashboard" ] }, + { "code": "EB05", "name": "Pie chart", "keywords": [ "chart", "analytics", "breakdown", "share", "distribution" ] }, + { "code": "E749", "name": "Print", "keywords": [ "printer", "paper" ] }, + { "code": "E722", "name": "Camera", "keywords": [ "photo", "snapshot", "capture" ] }, + { "code": "E8B9", "name": "Picture", "keywords": [ "image", "photo", "gallery", "media" ] }, + { "code": "E714", "name": "Video", "keywords": [ "film", "movie", "record", "media" ] }, + { "code": "E720", "name": "Microphone", "keywords": [ "mic", "record", "audio", "voice" ] }, + { "code": "E707", "name": "Location", "keywords": [ "map pin", "place", "geo", "region", "address" ] }, + { "code": "E717", "name": "Phone", "keywords": [ "call", "telephone", "mobile", "contact" ] }, + { "code": "E8CC", "name": "Mobile", "keywords": [ "tablet", "device", "phone", "handset" ] }, + { "code": "E772", "name": "Devices", "keywords": [ "pc", "computer", "hardware", "machines" ] }, + { "code": "E968", "name": "Server", "keywords": [ "host", "backend", "rack", "infrastructure" ] }, + { "code": "EBE8", "name": "Bug", "keywords": [ "issue", "defect", "error", "debug" ] }, + { "code": "E7BA", "name": "Warning", "keywords": [ "alert", "caution", "attention", "exclamation" ] }, + { "code": "E783", "name": "Error", "keywords": [ "important", "alert", "danger", "critical" ] }, + { "code": "E946", "name": "Info", "keywords": [ "information", "about", "details", "note" ] }, + { "code": "E897", "name": "Help", "keywords": [ "question", "support", "faq", "assist" ] }, + { "code": "EA80", "name": "Lightbulb", "keywords": [ "idea", "tip", "hint", "suggestion" ] }, + { "code": "E82D", "name": "Dictionary", "keywords": [ "book", "glossary", "reference", "definitions" ] }, + { "code": "E8F1", "name": "Library", "keywords": [ "books", "collection", "catalogue", "docs" ] }, + { "code": "E929", "name": "Notes", "keywords": [ "handwriting", "signature", "annotation", "scribble" ] }, + { "code": "E756", "name": "Terminal", "keywords": [ "command", "prompt", "shell", "console", "cli" ] }, + { "code": "E943", "name": "Code", "keywords": [ "script", "source", "developer", "programming" ] }, + { "code": "E90F", "name": "Repair", "keywords": [ "wrench", "tools", "fix", "maintenance", "spanner" ] } + ] +} diff --git a/src/Snipdeck.App/Bootstrap.cs b/src/Snipdeck.App/Bootstrap.cs index d2a2a1d..32ebf9d 100644 --- a/src/Snipdeck.App/Bootstrap.cs +++ b/src/Snipdeck.App/Bootstrap.cs @@ -49,7 +49,7 @@ public static IServiceProvider Build() .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton(new GlyphCatalogueProvider(pathProvider.IconCatalogueFilePath)) .AddSingleton() .AddSingleton() .AddSingleton(settingsStore) diff --git a/src/Snipdeck.App/Services/GlyphCatalogueProvider.cs b/src/Snipdeck.App/Services/GlyphCatalogueProvider.cs index 0f8952f..ffffbde 100644 --- a/src/Snipdeck.App/Services/GlyphCatalogueProvider.cs +++ b/src/Snipdeck.App/Services/GlyphCatalogueProvider.cs @@ -8,23 +8,78 @@ namespace Snipdeck.App.Services { /// - /// Reads the curated glyph catalogue from appsettings.json (next to the - /// executable). Re-reads on every call so editing the file is reflected the - /// next time the picker opens. Degrades to an empty catalogue — never throws — - /// when the file is missing or malformed, so a bad edit can't break the picker. + /// Supplies the icon picker's catalogue from a user-editable JSON file in + /// LocalAppData (so edits survive Velopack updates). On first run the file is + /// seeded from a default bundled into the app; thereafter the user's copy is + /// read, re-read on every call so edits take effect without a restart. + /// Degrades to the bundled default — never throws — when the user file is + /// missing or malformed, so a bad edit can't break the picker. /// internal sealed class GlyphCatalogueProvider : IGlyphCatalogueProvider { + // The bundled default, embedded so it can't be deleted and ships fresh + // with each release. Its logical name is pinned in the csproj. + private const string _defaultResourceName = "Snipdeck.App.icon-catalogue.json"; + private readonly string _path; - public GlyphCatalogueProvider() + public GlyphCatalogueProvider(string catalogueFilePath) { - _path = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + ArgumentException.ThrowIfNullOrWhiteSpace(catalogueFilePath); + _path = catalogueFilePath; } public IReadOnlyList GetEntries() { - GlyphCatalogueFile? file; + SeedIfMissing(); + + // The user's copy wins; fall back to the bundled default if it's + // absent or unreadable (e.g. mid-edit or malformed). + var fromFile = ReadFile(); + return fromFile.Count > 0 ? fromFile : ReadBundledDefault(); + } + + private void SeedIfMissing() + { + // Only seed when truly absent — never overwrite a user's file, even a + // broken one (they can fix it, or delete it to restore the default). + try + { + if (File.Exists(_path)) + { + return; + } + + var directory = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) + { + _ = Directory.CreateDirectory(directory); + } + + using var resource = OpenBundledDefault(); + if (resource is null) + { + return; + } + + // Write-then-rename so an interrupted seed can't leave a partial file. + var tempPath = _path + ".tmp"; + using (var file = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + resource.CopyTo(file); + file.Flush(); + } + + File.Move(tempPath, _path, overwrite: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Seeding is best-effort: the bundled default still serves the picker. + } + } + + private List ReadFile() + { try { if (!File.Exists(_path)) @@ -33,13 +88,33 @@ public IReadOnlyList GetEntries() } using var stream = File.OpenRead(_path); - file = JsonSerializer.Deserialize(stream, GlyphCatalogueJsonContext.Default.GlyphCatalogueFile); + return Parse(stream); } catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) { return []; } + } + + private static List ReadBundledDefault() + { + try + { + using var stream = OpenBundledDefault(); + return stream is null ? [] : Parse(stream); + } + catch (JsonException) + { + return []; + } + } + private static Stream? OpenBundledDefault() => + typeof(GlyphCatalogueProvider).Assembly.GetManifestResourceStream(_defaultResourceName); + + private static List Parse(Stream stream) + { + var file = JsonSerializer.Deserialize(stream, GlyphCatalogueJsonContext.Default.GlyphCatalogueFile); if (file?.GlyphCatalogue is not { Count: > 0 } raw) { return []; @@ -63,7 +138,7 @@ public IReadOnlyList GetEntries() } } - /// The appsettings.json shape the catalogue is read from. + /// The icon-catalogue JSON shape the catalogue is read from. internal sealed class GlyphCatalogueFile { public List? GlyphCatalogue { get; set; } diff --git a/src/Snipdeck.App/Services/WindowsPathProvider.cs b/src/Snipdeck.App/Services/WindowsPathProvider.cs index a2103ce..5680b7d 100644 --- a/src/Snipdeck.App/Services/WindowsPathProvider.cs +++ b/src/Snipdeck.App/Services/WindowsPathProvider.cs @@ -14,6 +14,8 @@ internal sealed class WindowsPathProvider : IPathProvider public string SettingsFilePath => DefaultPaths.SettingsFilePath; + public string IconCatalogueFilePath => DefaultPaths.IconCatalogueFilePath; + public string DefaultStorageDirectory => DefaultPaths.DefaultStorageDirectory; public string DefaultBackupDirectory => DefaultPaths.DefaultBackupDirectory; diff --git a/src/Snipdeck.App/Services/WindowsShellInteractions.cs b/src/Snipdeck.App/Services/WindowsShellInteractions.cs index 6bd71a0..c174208 100644 --- a/src/Snipdeck.App/Services/WindowsShellInteractions.cs +++ b/src/Snipdeck.App/Services/WindowsShellInteractions.cs @@ -133,7 +133,7 @@ public async Task NotifyAsync(string title, string message, string buttonText = public async Task PickGlyphAsync(string? currentGlyph) { - // Re-read the catalogue each open, so edits to appsettings.json take + // Re-read the catalogue each open, so edits to icon-catalogue.json take // effect without a restart. var picker = new GlyphPickerViewModel(_glyphCatalogue.GetEntries(), currentGlyph); var dialog = new GlyphPickerDialog(picker) diff --git a/src/Snipdeck.App/Snipdeck.App.csproj b/src/Snipdeck.App/Snipdeck.App.csproj index 0e4ccad..e678005 100644 --- a/src/Snipdeck.App/Snipdeck.App.csproj +++ b/src/Snipdeck.App/Snipdeck.App.csproj @@ -28,10 +28,15 @@ - + - + + Snipdeck.App.icon-catalogue.json + diff --git a/src/Snipdeck.App/Views/GlyphPickerDialog.xaml b/src/Snipdeck.App/Views/GlyphPickerDialog.xaml index ca13ecc..00be502 100644 --- a/src/Snipdeck.App/Views/GlyphPickerDialog.xaml +++ b/src/Snipdeck.App/Views/GlyphPickerDialog.xaml @@ -59,7 +59,7 @@ Visibility="{x:Bind ViewModel.HasNoResults, Mode=OneWay, Converter={StaticResource GlyphBoolToVisibility}}" /> - /// Supplies the glyph picker's browsable catalogue. The implementation lives - /// in the App project and reads the user-editable appsettings.json, so editing - /// that file (adding or removing glyphs) is reflected the next time the picker - /// opens — no rebuild required. + /// in the App project and reads the user-editable icon-catalogue.json, so + /// editing that file (adding or removing glyphs) is reflected the next time + /// the picker opens — no rebuild required. /// public interface IGlyphCatalogueProvider { diff --git a/src/Snipdeck.Core/Abstractions/IPathProvider.cs b/src/Snipdeck.Core/Abstractions/IPathProvider.cs index e422dfa..2ad2c65 100644 --- a/src/Snipdeck.Core/Abstractions/IPathProvider.cs +++ b/src/Snipdeck.Core/Abstractions/IPathProvider.cs @@ -6,6 +6,8 @@ public interface IPathProvider string SettingsFilePath { get; } + string IconCatalogueFilePath { get; } + string DefaultStorageDirectory { get; } string DefaultBackupDirectory { get; } diff --git a/src/Snipdeck.Core/Models/GlyphCatalogueEntry.cs b/src/Snipdeck.Core/Models/GlyphCatalogueEntry.cs index 72d12e6..c15c67a 100644 --- a/src/Snipdeck.Core/Models/GlyphCatalogueEntry.cs +++ b/src/Snipdeck.Core/Models/GlyphCatalogueEntry.cs @@ -5,7 +5,7 @@ namespace Snipdeck.Core.Models /// /// One entry in the glyph picker's browsable catalogue: a Segoe Fluent Icons /// glyph, a friendly name, and optional search keywords. The catalogue is a - /// curated, user-editable subset (see the App's appsettings.json) — not the + /// curated, user-editable subset (see the App's icon-catalogue.json) — not the /// full ~1.5k font, which would need heavier virtualisation to stay usable. /// /// The resolved glyph character to render (e.g. ""). diff --git a/src/Snipdeck.Core/Services/DefaultPaths.cs b/src/Snipdeck.Core/Services/DefaultPaths.cs index 998909d..4b0c8f5 100644 --- a/src/Snipdeck.Core/Services/DefaultPaths.cs +++ b/src/Snipdeck.Core/Services/DefaultPaths.cs @@ -13,6 +13,7 @@ public static class DefaultPaths { public const string AppFolderName = "Snipdeck"; public const string SettingsFileName = "settings.json"; + public const string IconCatalogueFileName = "icon-catalogue.json"; public const string StoreDirectoryName = "store"; public const string StoreFileName = "store.json"; public const string BackupsDirectoryName = "backups"; @@ -26,6 +27,13 @@ public static class DefaultPaths /// The app-config file, stored separately from the snip store. public static string SettingsFilePath { get; } = Path.Combine(AppDataDirectory, SettingsFileName); + /// + /// The user-editable icon picker catalogue. Lives here (not beside the + /// executable) so it survives Velopack updates and is editable per machine; + /// seeded from a bundled default on first run. + /// + public static string IconCatalogueFilePath { get; } = Path.Combine(AppDataDirectory, IconCatalogueFileName); + /// The default directory that holds the snip store document. public static string DefaultStorageDirectory { get; } = Path.Combine(AppDataDirectory, StoreDirectoryName); diff --git a/tests/Snipdeck.Core.Tests/Support/FakePathProvider.cs b/tests/Snipdeck.Core.Tests/Support/FakePathProvider.cs index 7d3f89d..b98b27c 100644 --- a/tests/Snipdeck.Core.Tests/Support/FakePathProvider.cs +++ b/tests/Snipdeck.Core.Tests/Support/FakePathProvider.cs @@ -8,6 +8,8 @@ public sealed class FakePathProvider : IPathProvider public string SettingsFilePath { get; init; } = "/data/settings.json"; + public string IconCatalogueFilePath { get; init; } = "/data/icon-catalogue.json"; + public string DefaultStorageDirectory { get; init; } = "/data/store"; public string DefaultBackupDirectory { get; init; } = "/data/backups";