From 00deea0ca1f1e397274bf068e7dfb98fe78a4ae9 Mon Sep 17 00:00:00 2001 From: JNIH Date: Thu, 30 Jul 2026 18:43:58 +0200 Subject: [PATCH] chore: format and clean up --- .editorconfig | 14 + Directory.Packages.props | 22 +- Olive/Commands/CopyGifCommand.cs | 69 ++- Olive/Helpers/IntegerSetting.cs | 67 +++ Olive/Helpers/SettingsManager.cs | 99 ++-- Olive/Klipy/KlipyClient.cs | 184 ++++---- Olive/Klipy/KlipyModels.cs | 39 +- Olive/Olive.csproj | 26 +- Olive/OliveCommandsProvider.cs | 39 +- Olive/OliveExtension.cs | 33 +- Olive/Package.appxmanifest | 30 +- Olive/Pages/GifListItemFactory.cs | 81 ++++ Olive/Pages/GifPickerPage.cs | 731 +++++++++-------------------- Olive/Pages/GifPickerPageState.cs | 21 + Olive/Program.cs | 56 +-- Olive/Services/ClipboardService.cs | 146 +++--- Olive/Services/GifCache.cs | 70 --- Olive/app.manifest | 6 +- README.md | 343 ++++++++++++-- nuget.config | 18 +- scripts/Uninstall-Olive.ps1 | 11 +- 21 files changed, 1134 insertions(+), 971 deletions(-) create mode 100644 .editorconfig create mode 100644 Olive/Helpers/IntegerSetting.cs create mode 100644 Olive/Pages/GifListItemFactory.cs create mode 100644 Olive/Pages/GifPickerPageState.cs delete mode 100644 Olive/Services/GifCache.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cc24c14 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 120 + +[*.md] +trim_trailing_whitespace = false +max_line_length = 0 \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index d06bf66..ffcaa87 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,16 +3,16 @@ true - - - - - - - - - - - + + + + + + + + + + + diff --git a/Olive/Commands/CopyGifCommand.cs b/Olive/Commands/CopyGifCommand.cs index c98a1b0..bb30416 100644 --- a/Olive/Commands/CopyGifCommand.cs +++ b/Olive/Commands/CopyGifCommand.cs @@ -7,45 +7,44 @@ namespace Olive.Commands; internal sealed partial class CopyGifCommand : InvokableCommand { - private readonly KlipyGif _gif; - private readonly GifCache _cache; + private readonly KlipyGif _gif; - public CopyGifCommand(KlipyGif gif, GifCache cache) - { - _gif = gif; - _cache = cache; - Name = "Copy GIF"; - Icon = new IconInfo("\uE8C8"); - } + public CopyGifCommand(KlipyGif gif) + { + _gif = gif; + Name = "Copy GIF"; + Icon = new IconInfo("\uE8C8"); + } - public override ICommandResult Invoke() - { - _ = CopyAsync(); - return CommandResult.KeepOpen(); - } + public override ICommandResult Invoke() + { + _ = CopyAsync(); + return CommandResult.Hide(); + } - private async Task CopyAsync() + private async Task CopyAsync() + { + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try { - using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - try - { - ShowStatus("Downloading GIF...", MessageState.Info, 800); - var path = await _cache.GetOrDownloadAsync(_gif, cancellation.Token).ConfigureAwait(false); - await ClipboardService.CopyGifFileAsync(path, _gif.GifUrl, _gif.Title, cancellation.Token).ConfigureAwait(false); - ShowStatus("GIF copied - paste with Ctrl + V", MessageState.Success, 3500); - } - catch (OperationCanceledException) - { - ShowStatus("Copy cancelled or timed out.", MessageState.Warning, 2500); - } - catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidOperationException or UnauthorizedAccessException) - { - ShowStatus("Could not copy this GIF.", MessageState.Error, 3500); - } + ShowStatus("Downloading GIF...", MessageState.Info, 800); + var bytes = await KlipyClient.DownloadGifBytesAsync(_gif.GifUrl, cancellation.Token).ConfigureAwait(false); + await ClipboardService.CopyGifBytesAsync(bytes, cancellation.Token).ConfigureAwait(false); + ShowStatus("GIF copied - paste with Ctrl + V", MessageState.Success, 3500); } + catch (OperationCanceledException) + { + ShowStatus("Copy cancelled or timed out.", MessageState.Warning, 2500); + } + catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidOperationException + or UnauthorizedAccessException) + { + ShowStatus("Could not copy this GIF.", MessageState.Error, 3500); + } + } - private static void ShowStatus(string message, MessageState state, int duration) - { - new ToastStatusMessage(new StatusMessage { Message = message, State = state }) { Duration = duration }.Show(); - } + private static void ShowStatus(string message, MessageState state, int duration) + { + new ToastStatusMessage(new StatusMessage { Message = message, State = state }) { Duration = duration }.Show(); + } } diff --git a/Olive/Helpers/IntegerSetting.cs b/Olive/Helpers/IntegerSetting.cs new file mode 100644 index 0000000..165c013 --- /dev/null +++ b/Olive/Helpers/IntegerSetting.cs @@ -0,0 +1,67 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Olive.Helpers; + +internal sealed class IntegerSetting : Setting +{ + public IntegerSetting(string key, string label, string description, int defaultValue, int minimum, int maximum) + : base(key, label, description, defaultValue) + { + Minimum = minimum; + Maximum = maximum; + } + + public int Minimum { get; } + + public int Maximum { get; } + + public string Placeholder { get; set; } = string.Empty; + + public override Dictionary ToDictionary() + { + return new Dictionary + { + { "type", "Input.Number" }, + { "title", Label }, + { "id", Key }, + { "label", Description }, + { "value", Value }, + { "placeholder", Placeholder }, + { "min", Minimum }, + { "max", Maximum }, + { "isRequired", IsRequired }, + { "errorMessage", ErrorMessage } + }; + } + + public override void Update(JsonObject payload) + { + if (payload[Key] is null) return; + + int value; + try + { + value = payload[Key]!.GetValue(); + } + catch (InvalidOperationException) + { + var text = payload[Key]?.GetValue() ?? string.Empty; + if (!int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) + { + ErrorMessage = + $"Enter a number between {Minimum.ToString(CultureInfo.InvariantCulture)} and {Maximum.ToString(CultureInfo.InvariantCulture)}."; + return; + } + } + + Value = Math.Clamp(value, Minimum, Maximum); + ErrorMessage = string.Empty; + } + + public override string ToState() + { + return $"\"{Key}\": {Value}"; + } +} diff --git a/Olive/Helpers/SettingsManager.cs b/Olive/Helpers/SettingsManager.cs index 495cdf3..cc4ecf0 100644 --- a/Olive/Helpers/SettingsManager.cs +++ b/Olive/Helpers/SettingsManager.cs @@ -2,51 +2,78 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Olive.Helpers; -internal sealed partial class SettingsManager : JsonSettingsManager +internal sealed class SettingsManager : JsonSettingsManager { - private const string Namespace = "Olive"; + private const string Namespace = "Olive"; - private readonly TextSetting _klipyApiKey = new( - Namespaced(nameof(KlipyApiKey)), - "Klipy API key", - "Private key used to call the Klipy API. It stays stored locally in Olive settings.", - string.Empty) + public SettingsManager() + { + FilePath = SettingsJsonPath(); + Settings.Add(_klipyApiKey); + Settings.Add(_resultCount); + + // Load settings from file upon initialization + LoadSettings(); + + Settings.SettingsChanged += (_, _) => SaveSettings(); + } + + public string SettingsPath => FilePath; + + private readonly TextSetting _klipyApiKey = new( + Namespaced(nameof(KlipyApiKey)), + "Klipy API key", + "Private key used to call the Klipy API. It stays stored locally in Olive settings.", + string.Empty) + { + Placeholder = "Required", + IsRequired = true, + ErrorMessage = "Klipy API key is required." + }; + + private readonly IntegerSetting _resultCount = new( + Namespaced(nameof(ResultCount)), + "Result count", + "Number of GIF results to load for each search.", + 50, + 20, + 100) + { + Placeholder = "Min 20, max 100", + IsRequired = true, + ErrorMessage = "Enter a number between 20 and 100." + }; + + public string KlipyApiKey + { + get { - Placeholder = "Paste your Klipy API key here", - }; - - private static string Namespaced(string propertyName) => $"{Namespace}.{propertyName}"; - - public string KlipyApiKey - { - get - { - LoadSettings(); - return _klipyApiKey.Value ?? string.Empty; - } + LoadSettings(); + return _klipyApiKey.Value ?? string.Empty; } + } - public bool HasKlipyApiKey => !string.IsNullOrWhiteSpace(KlipyApiKey); + public bool HasKlipyApiKey => !string.IsNullOrWhiteSpace(KlipyApiKey); - public string SettingsPath => FilePath; - - internal static string SettingsJsonPath() + public int ResultCount + { + get { - var directory = Utilities.BaseSettingsPath("Olive"); - Directory.CreateDirectory(directory); - - return Path.Combine(directory, "settings.json"); + LoadSettings(); + return Math.Clamp(_resultCount.Value, 30, 100); } + } - public SettingsManager() - { - FilePath = SettingsJsonPath(); + private static string SettingsJsonPath() + { + var directory = Utilities.BaseSettingsPath("Olive"); + Directory.CreateDirectory(directory); - Settings.Add(_klipyApiKey); + return Path.Combine(directory, "settings.json"); + } - // Load settings from file upon initialization - LoadSettings(); - - Settings.SettingsChanged += (_, _) => SaveSettings(); - } + private static string Namespaced(string propertyName) + { + return $"{Namespace}.{propertyName}"; + } } diff --git a/Olive/Klipy/KlipyClient.cs b/Olive/Klipy/KlipyClient.cs index f3a764c..c9d9b8a 100644 --- a/Olive/Klipy/KlipyClient.cs +++ b/Olive/Klipy/KlipyClient.cs @@ -6,98 +6,112 @@ namespace Olive.Klipy; internal sealed class KlipyClient { - private const string BaseUrl = "https://api.klipy.com"; - private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(12) }; + private const string BaseUrl = "https://api.klipy.com"; + private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(12) }; - public static async Task SearchAsync(string apiKey, string query, int page, int perPage, CancellationToken cancellationToken) + /// + /// Searches Klipy GIFs with the raw query parameters used by Olive and returns converted GIF items plus pagination state. + /// + /// Klipy application key used in the API route. + /// Search text entered by the user. + /// One-based Klipy page number to request. + /// Number of items to request from Klipy for this page. + /// Token used to cancel the HTTP request and JSON parsing. + /// Converted GIF results and whether Klipy reports another page. + public static async Task SearchAsync(string apiKey, string query, int page, int perPage, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(apiKey)) + throw new InvalidOperationException("The Klipy API key is missing. Set it in Olive settings."); + + var requestUri = BuildSearchUri(apiKey, query, page, perPage); + + using var response = await HttpClient + .GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NoContent) return new KlipySearchResult([], false); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"Klipy returned {(int)response.StatusCode} {response.ReasonPhrase}.", null, + response.StatusCode); + + var payload = await response.Content + .ReadFromJsonAsync(KlipyJsonContext.Default.KlipySearchResponse, cancellationToken).ConfigureAwait(false); + if (payload is null || !payload.Result) throw new HttpRequestException("Klipy did not return a usable result."); + + var rawItems = payload.Data?.Items ?? []; + var hasMore = payload.Data?.HasNext ?? rawItems.Length >= perPage; + return new KlipySearchResult(ConvertResults(rawItems), hasMore); + } + + /// + /// Downloads the selected animated GIF file into memory for clipboard use. + /// + /// Absolute URL of the GIF file to download. + /// Token used to cancel the download. + /// Raw GIF file bytes. + public static async Task DownloadGifBytesAsync(Uri gifUrl, CancellationToken cancellationToken) + { + using var response = await HttpClient.GetAsync(gifUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"Download failed ({(int)response.StatusCode}).", null, response.StatusCode); + + return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Builds the Klipy search endpoint URL with only page, per_page, and q query parameters. + /// + /// Klipy application key used in the API route. + /// Search text to pass as the q query parameter. + /// One-based Klipy page number to request. + /// Number of items to request from Klipy for this page. + /// Fully escaped Klipy search URI. + private static Uri BuildSearchUri(string apiKey, string query, int page, int perPage) + { + var endpoint = $"{BaseUrl}/api/v1/{Uri.EscapeDataString(apiKey)}/gifs/search"; + var parameters = new Dictionary { - if (string.IsNullOrWhiteSpace(apiKey)) - { - throw new InvalidOperationException("The Klipy API key is missing. Set it in Olive settings."); - } + ["page"] = page.ToString(CultureInfo.InvariantCulture), + ["per_page"] = perPage.ToString(CultureInfo.InvariantCulture), + ["q"] = query + }; - var requestUri = BuildSearchUri(apiKey, query, page, perPage); + var queryString = string.Join("&", parameters.Select(pair => string.Create( + CultureInfo.InvariantCulture, + $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"))); - using var response = await HttpClient.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.NoContent) - { - return new KlipySearchResult([], HasMore: false); - } + return new Uri($"{endpoint}?{queryString}"); + } - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"Klipy returned {(int)response.StatusCode} {response.ReasonPhrase}.", null, response.StatusCode); - } + /// + /// Converts raw Klipy response items into Olive GIF models and skips entries without usable GIF URLs. + /// + /// Raw item array from the Klipy search response. + /// GIF models that have an ID, thumbnail URL, and full GIF URL. + private static List ConvertResults(KlipyItem[]? items) + { + if (items is null || items.Length == 0) return []; - var payload = await response.Content.ReadFromJsonAsync(KlipyJsonContext.Default.KlipySearchResponse, cancellationToken).ConfigureAwait(false); - if (payload is null || !payload.Result) - { - throw new HttpRequestException("Klipy did not return a usable result."); - } + var gifs = new List(items.Length); + foreach (var item in items) + { + var id = !string.IsNullOrWhiteSpace(item.Slug) ? item.Slug : item.Id?.ToString(CultureInfo.InvariantCulture); + var thumbnailUrl = item.File?.Sm?.Gif?.Url ?? item.File?.Md?.Gif?.Url ?? item.File?.Hd?.Gif?.Url; + var fullUrl = item.File?.Hd?.Gif?.Url ?? item.File?.Md?.Gif?.Url ?? item.File?.Sm?.Gif?.Url; - var rawItems = payload.Data?.Items ?? []; - var hasMore = payload.Data?.HasNext ?? rawItems.Length >= perPage; - return new KlipySearchResult(ConvertResults(rawItems), hasMore); + if (string.IsNullOrWhiteSpace(id) + || !Uri.TryCreate(thumbnailUrl, UriKind.Absolute, out var thumbnailGifUrl) + || !Uri.TryCreate(fullUrl, UriKind.Absolute, out var gifUrl)) + continue; + + var title = string.IsNullOrWhiteSpace(item.Title) + ? "GIF Klipy" + : WebUtility.HtmlDecode(item.Title).Trim(); + + gifs.Add(new KlipyGif(id, title, thumbnailGifUrl, gifUrl)); } - public static async Task DownloadGifAsync(Uri gifUrl, string destinationPath, CancellationToken cancellationToken) - { - using var response = await HttpClient.GetAsync(gifUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"Download failed ({(int)response.StatusCode}).", null, response.StatusCode); - } - - await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using var output = File.Create(destinationPath); - await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); - } - - private static Uri BuildSearchUri(string apiKey, string query, int page, int perPage) - { - var endpoint = $"{BaseUrl}/api/v1/{Uri.EscapeDataString(apiKey)}/gifs/search"; - var parameters = new Dictionary - { - ["page"] = page.ToString(CultureInfo.InvariantCulture), - ["per_page"] = perPage.ToString(CultureInfo.InvariantCulture), - ["q"] = query, - }; - - var queryString = string.Join("&", parameters.Select(pair => string.Create( - CultureInfo.InvariantCulture, - $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"))); - - return new Uri($"{endpoint}?{queryString}"); - } - - private static List ConvertResults(KlipyItem[]? items) - { - if (items is null || items.Length == 0) - { - return []; - } - - var gifs = new List(items.Length); - foreach (var item in items) - { - var id = !string.IsNullOrWhiteSpace(item.Slug) ? item.Slug : item.Id?.ToString(CultureInfo.InvariantCulture); - var thumbnailUrl = item.File?.Sm?.Gif?.Url ?? item.File?.Md?.Gif?.Url ?? item.File?.Hd?.Gif?.Url; - var fullUrl = item.File?.Hd?.Gif?.Url ?? item.File?.Md?.Gif?.Url ?? item.File?.Sm?.Gif?.Url; - - if (string.IsNullOrWhiteSpace(id) - || !Uri.TryCreate(thumbnailUrl, UriKind.Absolute, out var thumbnailGifUrl) - || !Uri.TryCreate(fullUrl, UriKind.Absolute, out var gifUrl)) - { - continue; - } - - var title = string.IsNullOrWhiteSpace(item.Title) - ? "GIF Klipy" - : WebUtility.HtmlDecode(item.Title).Trim(); - - gifs.Add(new KlipyGif(id, title, thumbnailGifUrl, gifUrl)); - } - - return gifs; - } + return gifs; + } } diff --git a/Olive/Klipy/KlipyModels.cs b/Olive/Klipy/KlipyModels.cs index c93ff04..114cae1 100644 --- a/Olive/Klipy/KlipyModels.cs +++ b/Olive/Klipy/KlipyModels.cs @@ -3,39 +3,40 @@ using System.Text.Json.Serialization; namespace Olive.Klipy; internal sealed record KlipyGif( - string Id, - string Title, - Uri ThumbnailGifUrl, - Uri GifUrl); + string Id, + string Title, + Uri ThumbnailGifUrl, + Uri GifUrl); internal sealed record KlipySearchResult( - IReadOnlyList Gifs, - bool HasMore); + IReadOnlyList Gifs, + bool HasMore); internal sealed record KlipySearchResponse( - [property: JsonPropertyName("result")] bool Result, - [property: JsonPropertyName("data")] KlipySearchData? Data); + [property: JsonPropertyName("result")] bool Result, + [property: JsonPropertyName("data")] KlipySearchData? Data); internal sealed record KlipySearchData( - [property: JsonPropertyName("data")] KlipyItem[]? Items, - [property: JsonPropertyName("has_next")] bool? HasNext); + [property: JsonPropertyName("data")] KlipyItem[]? Items, + [property: JsonPropertyName("has_next")] + bool? HasNext); internal sealed record KlipyItem( - [property: JsonPropertyName("id")] long? Id, - [property: JsonPropertyName("slug")] string? Slug, - [property: JsonPropertyName("title")] string? Title, - [property: JsonPropertyName("file")] KlipyFile? File); + [property: JsonPropertyName("id")] long? Id, + [property: JsonPropertyName("slug")] string? Slug, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("file")] KlipyFile? File); internal sealed record KlipyFile( - [property: JsonPropertyName("hd")] KlipyFileSize? Hd, - [property: JsonPropertyName("md")] KlipyFileSize? Md, - [property: JsonPropertyName("sm")] KlipyFileSize? Sm); + [property: JsonPropertyName("hd")] KlipyFileSize? Hd, + [property: JsonPropertyName("md")] KlipyFileSize? Md, + [property: JsonPropertyName("sm")] KlipyFileSize? Sm); internal sealed record KlipyFileSize( - [property: JsonPropertyName("gif")] KlipyMediaFormat? Gif); + [property: JsonPropertyName("gif")] KlipyMediaFormat? Gif); internal sealed record KlipyMediaFormat( - [property: JsonPropertyName("url")] string? Url); + [property: JsonPropertyName("url")] string? Url); [JsonSerializable(typeof(KlipySearchResponse))] internal sealed partial class KlipyJsonContext : JsonSerializerContext; diff --git a/Olive/Olive.csproj b/Olive/Olive.csproj index 4afa0d7..7ff7906 100644 --- a/Olive/Olive.csproj +++ b/Olive/Olive.csproj @@ -18,16 +18,16 @@ - - - - - - + + + + + + - + - + - - - + + + @@ -74,11 +74,11 @@ - false - + true true true diff --git a/Olive/OliveCommandsProvider.cs b/Olive/OliveCommandsProvider.cs index 9c2efc4..19c7d09 100644 --- a/Olive/OliveCommandsProvider.cs +++ b/Olive/OliveCommandsProvider.cs @@ -7,25 +7,26 @@ namespace Olive; public sealed partial class OliveCommandsProvider : CommandProvider { - private readonly SettingsManager _settingsManager = new(); + private readonly SettingsManager _settingsManager = new(); - public OliveCommandsProvider() - { - DisplayName = "Olive"; - Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); - Settings = _settingsManager.Settings; - } + public OliveCommandsProvider() + { + DisplayName = "Olive"; + Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); + Settings = _settingsManager.Settings; + } - public override ICommandItem[] TopLevelCommands() - { - return [ - new CommandItem(new GifPickerPage(_settingsManager)) - { - Title = "Olive GIF Picker", - Subtitle = "Browse through lots of GIFs and find the one that suits you best!", - MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)], - Icon = Icon, - }, - ]; - } + public override ICommandItem[] TopLevelCommands() + { + return + [ + new CommandItem(new GifPickerPage(_settingsManager)) + { + Title = "Olive GIF Picker", + Subtitle = "Browse through lots of GIFs and find the one that suits you best!", + MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)], + Icon = Icon + } + ]; + } } diff --git a/Olive/OliveExtension.cs b/Olive/OliveExtension.cs index d0cda7a..316a61c 100644 --- a/Olive/OliveExtension.cs +++ b/Olive/OliveExtension.cs @@ -6,23 +6,26 @@ namespace Olive; [Guid("C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A")] public sealed partial class OliveExtension : IExtension, IDisposable { - private readonly ManualResetEvent _extensionDisposedEvent; + private readonly ManualResetEvent _extensionDisposedEvent; - private readonly OliveCommandsProvider _provider = new(); + private readonly OliveCommandsProvider _provider = new(); - public OliveExtension(ManualResetEvent extensionDisposedEvent) + public OliveExtension(ManualResetEvent extensionDisposedEvent) + { + _extensionDisposedEvent = extensionDisposedEvent; + } + + public object? GetProvider(ProviderType providerType) + { + return providerType switch { - _extensionDisposedEvent = extensionDisposedEvent; - } + ProviderType.Commands => _provider, + _ => null + }; + } - public object? GetProvider(ProviderType providerType) - { - return providerType switch - { - ProviderType.Commands => _provider, - _ => null - }; - } - - public void Dispose() => _extensionDisposedEvent.Set(); + public void Dispose() + { + _extensionDisposedEvent.Set(); + } } diff --git a/Olive/Package.appxmanifest b/Olive/Package.appxmanifest index 48db482..418058f 100644 --- a/Olive/Package.appxmanifest +++ b/Olive/Package.appxmanifest @@ -11,7 +11,7 @@ + Version="0.0.42.0"/> Olive Private @@ -19,8 +19,8 @@ - - + + @@ -29,35 +29,35 @@ + Executable="$targetnametoken$.exe" + EntryPoint="$targetentrypoint$"> - - + + - + + Id="Olive" + PublicFolder="Public" + DisplayName="Olive" + Description="Search and copy animated GIFs from Klipy. Powered by EndMove"> - + @@ -71,7 +71,7 @@ - - + + diff --git a/Olive/Pages/GifListItemFactory.cs b/Olive/Pages/GifListItemFactory.cs new file mode 100644 index 0000000..0f7b4bd --- /dev/null +++ b/Olive/Pages/GifListItemFactory.cs @@ -0,0 +1,81 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Olive.Commands; +using Olive.Helpers; +using Olive.Klipy; + +namespace Olive.Pages; + +internal sealed class GifListItemFactory +{ + private readonly IIconInfo _icon; + private readonly SettingsManager _settingsManager; + + public GifListItemFactory(IIconInfo icon, SettingsManager settingsManager) + { + _icon = icon; + _settingsManager = settingsManager; + } + + public static ListItem CreateGifItem(KlipyGif gif) + { + return new ListItem(new CopyGifCommand(gif)) + { + Title = string.Empty, + Subtitle = string.Empty, + Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()) + }; + } + + public CommandItem InitialContent() + { + return new CommandItem(new NoOpCommand()) + { + Title = _settingsManager.HasKlipyApiKey ? "Search GIFs" : "Klipy API key missing", + Subtitle = _settingsManager.HasKlipyApiKey + ? "Type something like thanks, excited, cat, or confused." + : "Open Olive settings and set the Klipy API key.", + Icon = _icon + }; + } + + public CommandItem LoadingContent(string search) + { + return new CommandItem(new NoOpCommand()) + { + Title = "Loading GIFs...", + Subtitle = $"Searching Klipy for \"{search}\"", + Icon = _icon + }; + } + + public CommandItem MissingApiKeyContent() + { + return new CommandItem(new NoOpCommand()) + { + Title = "Klipy API key missing", + Subtitle = "Open Olive settings and paste your Klipy API key.", + Icon = _icon + }; + } + + public CommandItem NoResultsContent(string search) + { + return new CommandItem(new NoOpCommand()) + { + Title = "No GIF found", + Subtitle = $"Try another search for \"{search}\".", + Icon = _icon + }; + } + + public CommandItem SearchErrorContent(Exception ex) + { + return new CommandItem(new NoOpCommand()) + { + Title = "Search failed", + Subtitle = ex.Message, + Icon = _icon + }; + } +} diff --git a/Olive/Pages/GifPickerPage.cs b/Olive/Pages/GifPickerPage.cs index 4b2ab07..ca5c11e 100644 --- a/Olive/Pages/GifPickerPage.cs +++ b/Olive/Pages/GifPickerPage.cs @@ -1,555 +1,278 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -using Olive.Commands; using Olive.Helpers; using Olive.Klipy; -using Olive.Services; using Windows.Foundation; -using Windows.System; namespace Olive.Pages; internal sealed partial class GifPickerPage : IDynamicListPage { - private const int PageSize = 30; - private const int InitialBatchPages = 1; - private const int LoadMoreBatchPages = 1; - private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500); + private const int KlipyMaxPageSize = 50; + private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500); - private readonly Lock _itemsLock = new(); - private readonly GifCache _cache = new(); - private readonly SettingsManager _settingsManager; - private readonly List _items = []; - private readonly IIconInfo _icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); - private readonly IGridProperties _gridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false }; - private CancellationTokenSource? _searchCancellation; - private string _searchText = string.Empty; - private string _activeSearch = string.Empty; - private string _title = "Olive"; - private int _searchVersion; - private int _loadRequestId; - private int _nextPage = 1; - private int _loadedItemCount; - private bool _hasMoreItems; - private bool _isLoading; - private bool _isLoadingMore; - private ICommandItem _emptyContent; + private readonly Lock _stateLock = new(); + private readonly SettingsManager _settingsManager; + private readonly GifListItemFactory _itemFactory; + private readonly GifPickerPageState _state; + private CancellationTokenSource? _searchCancellation; + private string _searchText = string.Empty; + private string _activeSearch = string.Empty; + private int _searchVersion; + private ICommandItem _emptyContent; - public GifPickerPage(SettingsManager settingsManager) + public GifPickerPage(SettingsManager settingsManager) + { + _settingsManager = settingsManager; + _itemFactory = new GifListItemFactory(Icon, _settingsManager); + _state = new GifPickerPageState(); + _emptyContent = _itemFactory.InitialContent(); + } + + public event TypedEventHandler? ItemsChanged; + + public event TypedEventHandler? PropChanged; + + public IIconInfo Icon { get; } = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); + + public string Id => string.Empty; + + public string Name => "Olive GIF Picker"; + + public string Title { get; private set; } = "Olive"; + + public OptionalColor AccentColor => default; + + public bool IsLoading + { + get; + private set { - _settingsManager = settingsManager; - _emptyContent = InitialContent(); + if (field == value) return; + + field = value; + RaisePropChanged(nameof(IsLoading)); + } + } + + public string PlaceholderText => "Search for GIFs..."; + + public bool ShowDetails => false; + + public bool HasMoreItems => false; + + public IFilters? Filters => null; + + public IGridProperties GridProperties { get; } = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false }; + + public ICommandItem EmptyContent + { + get => _emptyContent; + private set + { + _emptyContent = value; + RaisePropChanged(nameof(EmptyContent)); + } + } + + public string? SearchText + { + get => _searchText; + set + { + value ??= string.Empty; + if (StringComparer.Ordinal.Equals(_searchText, value)) return; + + _searchText = value; + UpdateSearchText(value); + } + } + + public IListItem[] GetItems() + { + lock (_stateLock) + { + return _state.Snapshot(); + } + } + + public void LoadMore() + { + } + + private void UpdateSearchText(string newSearch) + { + CancelSearch(); + + var trimmedSearch = newSearch.Trim(); + if (string.IsNullOrWhiteSpace(trimmedSearch)) + { + ResetSearchState(); + EmptyContent = _settingsManager.HasKlipyApiKey + ? _itemFactory.InitialContent() + : _itemFactory.MissingApiKeyContent(); + return; } - public event TypedEventHandler? ItemsChanged; + var version = Interlocked.Increment(ref _searchVersion); + var cancellation = new CancellationTokenSource(); + _searchCancellation = cancellation; - public event TypedEventHandler? PropChanged; + _ = SearchAfterDebounceAsync(trimmedSearch, version, cancellation.Token); + } - public IIconInfo Icon => _icon; + private void ResetSearchState() + { + Interlocked.Increment(ref _searchVersion); + IsLoading = false; + _activeSearch = string.Empty; + ReplaceState([]); + } - public string Id => string.Empty; + private void CancelSearch() + { + var cancellation = _searchCancellation; + _searchCancellation = null; + if (cancellation is null) return; - public string Name => "Olive GIF Picker"; - - public string Title => _title; - - public OptionalColor AccentColor => default; - - public bool IsLoading + try + { + cancellation.Cancel(); + } + catch (ObjectDisposedException) { - get => _isLoading; - private set - { - if (_isLoading == value) - { - return; - } - - _isLoading = value; - RaisePropChanged(nameof(IsLoading)); - } } - public string PlaceholderText => "Search for GIFs..."; + cancellation.Dispose(); + } - public bool ShowDetails => false; - - public bool HasMoreItems + private async Task SearchAfterDebounceAsync(string search, int version, CancellationToken cancellationToken) + { + try { - get => _hasMoreItems; - private set - { - if (_hasMoreItems == value) - { - return; - } + await Task.Delay(SearchDebounceDelay, cancellationToken).ConfigureAwait(false); - _hasMoreItems = value; - RaisePropChanged(nameof(HasMoreItems)); - } - } + var apiKey = _settingsManager.KlipyApiKey; + if (string.IsNullOrWhiteSpace(apiKey)) + { + if (IsStale(version, cancellationToken)) return; - public IFilters? Filters => null; - - public IGridProperties GridProperties => _gridProperties; - - public ICommandItem EmptyContent - { - get => _emptyContent; - private set - { - _emptyContent = value; - RaisePropChanged(nameof(EmptyContent)); - } - } - - public string? SearchText - { - get => _searchText; - set - { - value ??= string.Empty; - if (StringComparer.Ordinal.Equals(_searchText, value)) - { - return; - } - - _searchText = value; - UpdateSearchText(value); - } - } - - public IListItem[] GetItems() - { - lock (_itemsLock) - { - return BuildDisplayItemsLocked(); - } - } - - public void LoadMore() - { - if (_isLoadingMore || !HasMoreItems || string.IsNullOrWhiteSpace(_activeSearch)) - { - return; - } - - var apiKey = _settingsManager.KlipyApiKey; - if (string.IsNullOrWhiteSpace(apiKey)) - { - HasMoreItems = false; - EmptyContent = MissingApiKeyContent(); - RaiseItemsChanged(DisplayItemCount()); - return; - } - - var version = _searchVersion; - _ = LoadPageBatchAsync(apiKey, _activeSearch, version, LoadMoreBatchPages, CancellationToken.None); - } - - private void UpdateSearchText(string newSearch) - { - CancelSearch(); - - var trimmedSearch = newSearch.Trim(); - if (string.IsNullOrWhiteSpace(trimmedSearch)) - { - Interlocked.Increment(ref _searchVersion); - Interlocked.Increment(ref _loadRequestId); - IsLoading = false; - _isLoadingMore = false; - HasMoreItems = false; - _activeSearch = string.Empty; - _nextPage = 1; - ReplaceItems([]); - UpdateTitle(); - EmptyContent = _settingsManager.HasKlipyApiKey ? InitialContent() : MissingApiKeyContent(); - return; - } - - var version = Interlocked.Increment(ref _searchVersion); - Interlocked.Increment(ref _loadRequestId); - var cancellation = new CancellationTokenSource(); - _searchCancellation = cancellation; - - _ = SearchAfterDebounceAsync(trimmedSearch, version, cancellation.Token); - } - - private void CancelSearch() - { - var cancellation = _searchCancellation; - _searchCancellation = null; - if (cancellation is null) - { - return; - } - - try - { - cancellation.Cancel(); - } - catch (ObjectDisposedException) - { - } - - cancellation.Dispose(); - } - - private async Task SearchAfterDebounceAsync(string search, int version, CancellationToken cancellationToken) - { - try - { - await Task.Delay(SearchDebounceDelay, cancellationToken).ConfigureAwait(false); - - var apiKey = _settingsManager.KlipyApiKey; - if (string.IsNullOrWhiteSpace(apiKey)) - { - if (version != _searchVersion || cancellationToken.IsCancellationRequested) - { - return; - } - - IsLoading = false; - HasMoreItems = false; - EmptyContent = MissingApiKeyContent(); - ReplaceItems([]); - return; - } - - if (version != _searchVersion || cancellationToken.IsCancellationRequested) - { - return; - } - - _activeSearch = search; - _nextPage = 1; - _isLoadingMore = false; - HasMoreItems = false; - IsLoading = true; - EmptyContent = LoadingContent(search); - UpdateTitle(); - - await LoadPageBatchAsync(apiKey, search, version, InitialBatchPages, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - if (version == _searchVersion) - { - ShowSearchError(ex, resetItems: true); - } - } - } - - private async Task LoadPageBatchAsync(string apiKey, string search, int version, int pageCount, CancellationToken cancellationToken) - { - if (_isLoadingMore) - { - return; - } - - var loadRequestId = Interlocked.Increment(ref _loadRequestId); - var startPage = _nextPage; - var resetItems = startPage == 1; - - try - { - _isLoadingMore = true; - IsLoading = true; - - var gifs = new List(PageSize * pageCount); - var nextPage = startPage; - var hasMore = false; - - for (var i = 0; i < pageCount && version == _searchVersion && !cancellationToken.IsCancellationRequested; i++) - { - var page = startPage + i; - var result = await KlipyClient.SearchAsync(apiKey, search, page, PageSize, cancellationToken).ConfigureAwait(false); - if (version != _searchVersion || cancellationToken.IsCancellationRequested) - { - return; - } - - gifs.AddRange(result.Gifs); - nextPage = page + 1; - hasMore = result.HasMore; - if (!hasMore) - { - break; - } - } - - _nextPage = nextPage; - HasMoreItems = hasMore; - - if (resetItems && gifs.Count == 0) - { - EmptyContent = new CommandItem(new NoOpCommand()) - { - Title = "No GIF found", - Subtitle = $"Try another search for \"{search}\".", - Icon = Icon, - }; - ReplaceItems([]); - return; - } - - var newItems = gifs.Select(CreateItem).ToArray(); - if (resetItems) - { - ReplaceItems(newItems); - } - else - { - AppendItems(newItems); - } - } - catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException or TaskCanceledException) - { - if (version == _searchVersion) - { - ShowSearchError(ex, resetItems); - } - } - catch (Exception ex) - { - if (version == _searchVersion) - { - ShowSearchError(ex, resetItems); - } - } - finally - { - if (loadRequestId == _loadRequestId) - { - _isLoadingMore = false; - } - - if (version == _searchVersion && loadRequestId == _loadRequestId) - { - IsLoading = false; - } - } - } - - private void ShowSearchError(Exception ex, bool resetItems) - { IsLoading = false; - HasMoreItems = false; - EmptyContent = new CommandItem(new NoOpCommand()) - { - Title = "Search failed", - Subtitle = ex.Message, - Icon = Icon, - }; + EmptyContent = _itemFactory.MissingApiKeyContent(); + ReplaceState([]); + return; + } - if (resetItems) - { - ReplaceItems([]); - } - else - { - RaiseItemsChanged(DisplayItemCount()); - } + if (IsStale(version, cancellationToken)) return; + + _activeSearch = search; + IsLoading = true; + EmptyContent = _itemFactory.LoadingContent(search); + UpdateTitle(); + + var gifs = await SearchResultsAsync(apiKey, search, _settingsManager.ResultCount, cancellationToken) + .ConfigureAwait(false); + if (IsStale(version, cancellationToken)) return; + + if (gifs.Count == 0) + { + EmptyContent = _itemFactory.NoResultsContent(search); + ReplaceState([]); + return; + } + + ReplaceState(gifs.Select(GifListItemFactory.CreateGifItem).ToArray()); } - - private ListItem CreateItem(KlipyGif gif) + catch (OperationCanceledException) { - return new ListItem(new CopyGifCommand(gif, _cache)) - { - Title = string.Empty, - Subtitle = string.Empty, - Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()), - MoreCommands = CreateLoadMoreContextItems(), - }; } - - private IContextItem[] CreateLoadMoreContextItems() + catch (Exception ex) { - return [ - CreateLoadMoreContextItem("Load 30 more GIFs", VirtualKey.L), - ]; + if (version == _searchVersion) ShowSearchError(ex); } - - private CommandContextItem CreateLoadMoreContextItem(string title, VirtualKey key) + finally { - return new CommandContextItem(new LoadMoreGifsCommand(LoadMore)) - { - Title = title, - Subtitle = "Append the next 30 results without moving the current selection", - Icon = Icon, - RequestedShortcut = KeyChordHelpers.FromModifiers(true, false, false, false, key, 0), - }; + if (version == _searchVersion) IsLoading = false; } + } - private CommandItem InitialContent() + private static async Task> SearchResultsAsync( + string apiKey, + string search, + int resultCount, + CancellationToken cancellationToken) + { + var gifs = new List(resultCount); + var page = 1; + + while (gifs.Count < resultCount) { - return new CommandItem(new NoOpCommand()) - { - Title = _settingsManager.HasKlipyApiKey ? "Search GIFs" : "Klipy API key missing", - Subtitle = _settingsManager.HasKlipyApiKey - ? "Type something like thanks, excited, cat, or confused." - : "Open Olive settings and set the Klipy API key.", - Icon = Icon, - }; + var perPage = Math.Min(KlipyMaxPageSize, resultCount - gifs.Count); + var result = await KlipyClient.SearchAsync(apiKey, search, page, perPage, cancellationToken) + .ConfigureAwait(false); + + gifs.AddRange(result.Gifs.Take(resultCount - gifs.Count)); + if (!result.HasMore || result.Gifs.Count == 0) break; + + page++; } - private CommandItem LoadingContent(string search) + return gifs; + } + + private void ShowSearchError(Exception ex) + { + IsLoading = false; + EmptyContent = _itemFactory.SearchErrorContent(ex); + ReplaceState([]); + } + + private void ReplaceState(IListItem[] items) + { + int count; + lock (_stateLock) { - return new CommandItem(new NoOpCommand()) - { - Title = "Loading GIFs...", - Subtitle = $"Searching Klipy for \"{search}\"", - Icon = Icon, - }; + _state.ReplaceItems(items); + count = _state.DisplayItemCount; } - private CommandItem MissingApiKeyContent() + RaiseItemsChanged(count); + UpdateTitle(); + } + + private int DisplayItemCount() + { + lock (_stateLock) { - return new CommandItem(new NoOpCommand()) - { - Title = "Klipy API key missing", - Subtitle = "Open Olive settings and paste your Klipy API key.", - Icon = Icon, - }; + return _state.DisplayItemCount; } + } - private void ReplaceItems(IListItem[] items) - { - int count; - lock (_itemsLock) - { - _items.Clear(); - _items.AddRange(items); - _loadedItemCount = items.Length; - AddPlaceholderItemsLocked(); - count = DisplayItemCountLocked(); - } + private bool IsStale(int version, CancellationToken cancellationToken) + { + return version != _searchVersion || cancellationToken.IsCancellationRequested; + } - RaiseItemsChanged(count); - UpdateTitle(); - } + private void UpdateTitle() + { + var loadedCount = DisplayItemCount(); + var title = string.IsNullOrWhiteSpace(_activeSearch) || loadedCount == 0 + ? "Olive" + : $"Olive - {loadedCount} GIFs loaded"; - private void AppendItems(IListItem[] items) - { - int count; - lock (_itemsLock) - { - RemovePlaceholderItemsLocked(); - _items.AddRange(items); - _loadedItemCount += items.Length; - AddPlaceholderItemsLocked(); - count = DisplayItemCountLocked(); - } + if (StringComparer.Ordinal.Equals(Title, title)) return; - RaiseItemsChanged(count); - UpdateTitle(); - } + Title = title; + RaisePropChanged(nameof(Title)); + } - private IListItem[] BuildDisplayItemsLocked() - { - return [.. _items]; - } - - private void AddPlaceholderItemsLocked() - { - if (!HasMoreItems) - { - return; - } - - for (var i = 0; i < PageSize * LoadMoreBatchPages; i++) - { - _items.Add(CreateVirtualLoadingItem()); - } - } - - private void RemovePlaceholderItemsLocked() - { - if (_items.Count > _loadedItemCount) - { - _items.RemoveRange(_loadedItemCount, _items.Count - _loadedItemCount); - } - } - - private ListItem CreateVirtualLoadingItem() - { - return new ListItem(new LoadMoreGifsCommand(LoadMore)) - { - Title = "Loading more GIFs...", - Subtitle = "Scroll here or press Ctrl+L to load the next 30", - Icon = Icon, - MoreCommands = CreateLoadMoreContextItems(), - }; - } - - private int DisplayItemCount() - { - lock (_itemsLock) - { - return DisplayItemCountLocked(); - } - } - - private int DisplayItemCountLocked() - { - return _items.Count; - } - - private void RaiseItemsChanged(int count) - { - ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(count)); - } - - private void RaisePropChanged(string propertyName) - { - PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName)); - } - - private void UpdateTitle() - { - var loadedCount = LoadedItemCount(); - var title = string.IsNullOrWhiteSpace(_activeSearch) || loadedCount == 0 - ? "Olive" - : HasMoreItems - ? $"Olive - {loadedCount} GIFs loaded" - : $"Olive - {loadedCount} GIFs loaded (all)"; - - if (StringComparer.Ordinal.Equals(_title, title)) - { - return; - } - - _title = title; - RaisePropChanged(nameof(Title)); - } - - private int LoadedItemCount() - { - lock (_itemsLock) - { - return _loadedItemCount; - } - } - - private sealed partial class LoadMoreGifsCommand : InvokableCommand - { - private readonly Action _loadMore; - - public LoadMoreGifsCommand(Action loadMore) - { - _loadMore = loadMore; - Name = "Load 30 more GIFs"; - Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); - } - - public override ICommandResult Invoke() - { - _loadMore(); - return CommandResult.KeepOpen(); - } - } + private void RaiseItemsChanged(int count) + { + ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(count)); + } + private void RaisePropChanged(string propertyName) + { + PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName)); + } } diff --git a/Olive/Pages/GifPickerPageState.cs b/Olive/Pages/GifPickerPageState.cs new file mode 100644 index 0000000..ac295bf --- /dev/null +++ b/Olive/Pages/GifPickerPageState.cs @@ -0,0 +1,21 @@ +using Microsoft.CommandPalette.Extensions; + +namespace Olive.Pages; + +internal sealed class GifPickerPageState +{ + private readonly List _items = []; + + public int DisplayItemCount => _items.Count; + + public IListItem[] Snapshot() + { + return [.. _items]; + } + + public void ReplaceItems(IListItem[] items) + { + _items.Clear(); + _items.AddRange(items); + } +} diff --git a/Olive/Program.cs b/Olive/Program.cs index 3620396..fe86598 100644 --- a/Olive/Program.cs +++ b/Olive/Program.cs @@ -5,35 +5,35 @@ namespace Olive; public class Program { - [MTAThread] - public static void Main(string[] args) + [MTAThread] + public static void Main(string[] args) + { + if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer") { - if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer") - { - Shmuelie.WinRTServer.ComServer server = new(); + Shmuelie.WinRTServer.ComServer server = new(); - ManualResetEvent extensionDisposedEvent = new(false); - - // We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called. - // This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object. - // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate. - OliveExtension extensionInstance = new(extensionDisposedEvent); - server.RegisterClass(() => extensionInstance); - server.Start(); - - // This will make the main thread wait until the event is signalled by the extension class. - // Since we have single instance of the extension object, we exit as soon as it is disposed. - extensionDisposedEvent.WaitOne(); - server.Stop(); - server.UnsafeDispose(); - } - else - { - System.Windows.Forms.MessageBox.Show( - "Olive is a PowerToys Command Palette extension.\n\nOpen PowerToys Command Palette, then launch 'Olive GIF Picker' to search and copy GIFs.", - "Olive", - System.Windows.Forms.MessageBoxButtons.OK, - System.Windows.Forms.MessageBoxIcon.Information); - } + ManualResetEvent extensionDisposedEvent = new(false); + + // We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called. + // This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object. + // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate. + OliveExtension extensionInstance = new(extensionDisposedEvent); + server.RegisterClass(() => extensionInstance); + server.Start(); + + // This will make the main thread wait until the event is signalled by the extension class. + // Since we have single instance of the extension object, we exit as soon as it is disposed. + extensionDisposedEvent.WaitOne(); + server.Stop(); + server.UnsafeDispose(); } + else + { + System.Windows.Forms.MessageBox.Show( + "Olive is a PowerToys Command Palette extension.\n\nOpen PowerToys Command Palette, then launch 'Olive GIF Picker' to search and copy GIFs.", + "Olive", + System.Windows.Forms.MessageBoxButtons.OK, + System.Windows.Forms.MessageBoxIcon.Information); + } + } } diff --git a/Olive/Services/ClipboardService.cs b/Olive/Services/ClipboardService.cs index 0da40a5..97d06ca 100644 --- a/Olive/Services/ClipboardService.cs +++ b/Olive/Services/ClipboardService.cs @@ -11,97 +11,93 @@ namespace Olive.Services; internal sealed class ClipboardService { - public static async Task CopyGifFileAsync(string gifPath, Uri gifUrl, string title, CancellationToken cancellationToken) + public static async Task CopyGifBytesAsync(byte[] gifBytes, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(gifBytes); + if (gifBytes.Length == 0) throw new InvalidOperationException("GIF data is empty."); + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => CopyOnStaThread(gifBytes, completion)) { - ArgumentException.ThrowIfNullOrWhiteSpace(gifPath); - ArgumentNullException.ThrowIfNull(gifUrl); + Name = "Olive clipboard STA" + }; - var fullPath = Path.GetFullPath(gifPath); - if (!File.Exists(fullPath)) - { - throw new FileNotFoundException("GIF file not found.", fullPath); - } + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var thread = new Thread(() => CopyOnStaThread(fullPath, completion)); + cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken)); + await completion.Task.ConfigureAwait(false); + } - thread.Name = "Olive clipboard STA"; - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - - cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken)); - await completion.Task.ConfigureAwait(false); - } - - private static void CopyOnStaThread(string fullPath, TaskCompletionSource completion) + private static void CopyOnStaThread(byte[] gifBytes, TaskCompletionSource completion) + { + try { + var clipboardHtml = BuildClipboardHtml(gifBytes); + + const int retryCount = 5; + for (var attempt = 1; attempt <= retryCount; attempt++) try { - var gifBytes = File.ReadAllBytes(fullPath); - var clipboardHtml = BuildClipboardHtml(gifBytes); - - const int retryCount = 5; - for (var attempt = 1; attempt <= retryCount; attempt++) - { - try - { - var data = new DataObject(); - data.SetData("image/gif", autoConvert: false, data: GifStream(gifBytes)); - data.SetData("GIF", autoConvert: false, data: GifStream(gifBytes)); - data.SetData(DataFormats.Html, autoConvert: false, data: clipboardHtml); - data.SetData("text/html", autoConvert: false, data: clipboardHtml); - TryAddBitmapPreview(data, fullPath); - FormsClipboard.SetDataObject(data, copy: true, retryTimes: 10, retryDelay: 100); - completion.TrySetResult(); - return; - } - catch (ExternalException) when (attempt < retryCount) - { - Thread.Sleep(120); - } - } - - completion.TrySetException(new InvalidOperationException("The clipboard is temporarily unavailable.")); + var data = new DataObject(); + data.SetData("image/gif", false, GifStream(gifBytes)); + data.SetData("GIF", false, GifStream(gifBytes)); + data.SetData(DataFormats.Html, false, clipboardHtml); + data.SetData("text/html", false, clipboardHtml); + TryAddBitmapPreview(data, gifBytes); + FormsClipboard.SetDataObject(data, true, 10, 100); + completion.TrySetResult(); + return; } - catch (Exception ex) + catch (ExternalException) when (attempt < retryCount) { - completion.TrySetException(ex); + Thread.Sleep(120); } - } - private static MemoryStream GifStream(byte[] gifBytes) + completion.TrySetException(new InvalidOperationException("The clipboard is temporarily unavailable.")); + } + catch (Exception ex) { - return new MemoryStream(gifBytes, writable: false); + completion.TrySetException(ex); } + } - private static void TryAddBitmapPreview(DataObject data, string fullPath) + private static MemoryStream GifStream(byte[] gifBytes) + { + return new MemoryStream(gifBytes, false); + } + + private static void TryAddBitmapPreview(DataObject data, byte[] gifBytes) + { + try { - try - { - using var image = Image.FromFile(fullPath); - data.SetData(DataFormats.Bitmap, autoConvert: true, data: new Bitmap(image)); - } - catch (Exception ex) when (ex is ArgumentException or ExternalException or OutOfMemoryException) - { - } + using var stream = new MemoryStream(gifBytes, false); + using var image = Image.FromStream(stream); + data.SetData(DataFormats.Bitmap, true, new Bitmap(image)); } - - private static string BuildClipboardHtml(byte[] gifBytes) + catch (Exception ex) when (ex is ArgumentException or ExternalException or OutOfMemoryException) { - var dataUri = "data:image/gif;base64," + Convert.ToBase64String(gifBytes); - var fragment = $"\"GIF\""; - const string markerPrefix = "Version:1.0\r\nStartHTML:{0:0000000000}\r\nEndHTML:{1:0000000000}\r\nStartFragment:{2:0000000000}\r\nEndFragment:{3:0000000000}\r\n"; - var prefix = string.Format(CultureInfo.InvariantCulture, markerPrefix, 0, 0, 0, 0); - const string beforeFragment = ""; - const string afterFragment = ""; - var html = beforeFragment + fragment + afterFragment; - - var startHtml = Encoding.UTF8.GetByteCount(prefix); - var startFragment = startHtml + Encoding.UTF8.GetByteCount(beforeFragment); - var endFragment = startFragment + Encoding.UTF8.GetByteCount(fragment); - var endHtml = startHtml + Encoding.UTF8.GetByteCount(html); - - var header = string.Format(CultureInfo.InvariantCulture, markerPrefix, startHtml, endHtml, startFragment, endFragment); - return header + html; } + } + + private static string BuildClipboardHtml(byte[] gifBytes) + { + var dataUri = "data:image/gif;base64," + Convert.ToBase64String(gifBytes); + var fragment = $"\"GIF\""; + const string markerPrefix = + "Version:1.0\r\nStartHTML:{0:0000000000}\r\nEndHTML:{1:0000000000}\r\nStartFragment:{2:0000000000}\r\nEndFragment:{3:0000000000}\r\n"; + var prefix = string.Format(CultureInfo.InvariantCulture, markerPrefix, 0, 0, 0, 0); + const string beforeFragment = ""; + const string afterFragment = ""; + var html = beforeFragment + fragment + afterFragment; + + var startHtml = Encoding.UTF8.GetByteCount(prefix); + var startFragment = startHtml + Encoding.UTF8.GetByteCount(beforeFragment); + var endFragment = startFragment + Encoding.UTF8.GetByteCount(fragment); + var endHtml = startHtml + Encoding.UTF8.GetByteCount(html); + + var header = string.Format(CultureInfo.InvariantCulture, markerPrefix, startHtml, endHtml, startFragment, + endFragment); + return header + html; + } } diff --git a/Olive/Services/GifCache.cs b/Olive/Services/GifCache.cs deleted file mode 100644 index baaa94d..0000000 --- a/Olive/Services/GifCache.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; -using Olive.Klipy; - -namespace Olive.Services; - -internal sealed partial class GifCache -{ - private readonly string _cacheDirectory; - private readonly ConcurrentDictionary _locks = new(StringComparer.OrdinalIgnoreCase); - - public GifCache() - { - _cacheDirectory = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "Olive", - "Cache"); - } - - public async Task GetOrDownloadAsync(KlipyGif gif, CancellationToken cancellationToken) - { - Directory.CreateDirectory(_cacheDirectory); - - var fileName = SafeFileName(gif.Id) + ".gif"; - var finalPath = Path.Combine(_cacheDirectory, fileName); - if (File.Exists(finalPath)) - { - return finalPath; - } - - var gate = _locks.GetOrAdd(fileName, _ => new SemaphoreSlim(1, 1)); - await gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - if (File.Exists(finalPath)) - { - return finalPath; - } - - var tempPath = Path.Combine(_cacheDirectory, fileName + "." + Guid.NewGuid().ToString("N") + ".tmp"); - try - { - await KlipyClient.DownloadGifAsync(gif.GifUrl, tempPath, cancellationToken).ConfigureAwait(false); - File.Move(tempPath, finalPath, overwrite: true); - } - finally - { - if (File.Exists(tempPath)) - { - File.Delete(tempPath); - } - } - - return finalPath; - } - finally - { - gate.Release(); - } - } - - private static string SafeFileName(string id) - { - var safe = UnsafeFileNameCharacters().Replace(id, "_"); - return string.IsNullOrWhiteSpace(safe) ? Guid.NewGuid().ToString("N") : safe; - } - - [GeneratedRegex("[^a-zA-Z0-9_.-]+")] - private static partial Regex UnsafeFileNameCharacters(); -} diff --git a/Olive/app.manifest b/Olive/app.manifest index 4c1ed0c..6a60d0f 100644 --- a/Olive/app.manifest +++ b/Olive/app.manifest @@ -4,11 +4,11 @@ - - + - + diff --git a/README.md b/README.md index d14e745..1891ca8 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,120 @@ # Olive -Olive is a private Microsoft PowerToys Command Palette extension. It searches animated GIFs with Klipy and allow you to quickly the one that suits you best! +Olive +is +a +private +Microsoft +PowerToys +Command +Palette +extension. +It +searches +animated +GIFs +with +Klipy +and +allow +you +to +quickly +the +one +that +suits +you +best! ## Requirements -- Windows 10/11 with PowerToys and Command Palette enabled. -- .NET SDK 10. -- Windows SDK `10.0.22621.0` or a compatible newer SDK. -- A Klipy API key. +- + +Windows +10/11 +with +PowerToys +and +Command +Palette +enabled. + +- + +.NET +SDK + +10. + +- + +Windows +SDK +`10.0.22621.0` +or +a +compatible +newer +SDK. + +- + +A +Klipy +API +key. ## Klipy API Key -Olive stores the Klipy key locally in its Command Palette settings. Do not put the key in source code, Git, environment variables, or the package. +Olive +stores +the +Klipy +key +locally +in +its +Command +Palette +settings. +Do +not +put +the +key +in +source +code, +Git, +environment +variables, +or +the +package. -To configure it, open Command Palette, open the context commands for `Olive GIF Picker`, open Olive settings, paste the key into `Klipy API key`, then save. +To +configure +it, +open +Command +Palette, +open +the +context +commands +for +`Olive GIF Picker`, +open +Olive +settings, +paste +the +key +into +`Klipy API key`, +then +save. ## Build @@ -27,71 +128,251 @@ dotnet build .\Olive.sln -c Debug -p:Platform=x64 .\scripts\Build-OlivePackage.ps1 ``` -Or double-click: +Or +double-click: ```text scripts\Build-OlivePackage.cmd ``` -This creates the shareable package in `dist\OlivePackage`. +This +creates +the +shareable +package +in +`dist\OlivePackage`. -The private signing file is exported to `dist\private\OlivePrivate.pfx`. Do not share it unless the recipient must be able to sign Olive builds. +The +private +signing +file +is +exported +to +`dist\private\OlivePrivate.pfx`. +Do +not +share +it +unless +the +recipient +must +be +able +to +sign +Olive +builds. ## Install -From `dist\OlivePackage`: +From +`dist\OlivePackage`: ```powershell .\Install-Olive.ps1 ``` -Or double-click: +Or +double-click: ```text Install-Olive.cmd ``` -The script asks for administrator rights, imports `OlivePrivate.cer` into `Cert:\LocalMachine\Root`, then opens the `.msix` with Windows App Installer. +The +script +asks +for +administrator +rights, +imports +`OlivePrivate.cer` +into +`Cert:\LocalMachine\Root`, +then +opens +the +`.msix` +with +Windows +App +Installer. -Restart PowerToys after installation. +Restart +PowerToys +after +installation. ## Uninstall -From `dist\OlivePackage` or `scripts\`: +From +`dist\OlivePackage` +or +`scripts\`: ```powershell .\Uninstall-Olive.ps1 ``` -Or double-click: +Or +double-click: ```text Uninstall-Olive.cmd ``` -By default, uninstall removes the Olive package, the Olive certificate from `Cert:\LocalMachine\Root`, local settings, cached GIFs, and MSIX user data. +By +default, +uninstall +removes +the +Olive +package, +the +Olive +certificate +from +`Cert:\LocalMachine\Root`, +and +Olive +user +data. -Use `-KeepCertificate` to keep the certificate. +Use +`-KeepCertificate` +to +keep +the +certificate. -Use `-KeepUserData` to keep local settings and cached GIFs. +Use +`-KeepUserData` +to +keep +Olive +user +data. ## Usage -1. Open PowerToys Command Palette. -2. Launch `Olive GIF Picker`. -3. Search for a GIF. -4. Select a result with Enter. -5. Paste with Ctrl+V in an app that accepts animated GIF files. +1. + +Open +PowerToys +Command +Palette. + +2. + +Launch +`Olive GIF Picker`. + +3. + +Search +for +a +GIF. + +4. + +Select +a +result +with +Enter. + +5. + +Paste +with +Ctrl+V +in +an +app +that +accepts +animated +GIF +files. ## Sharing -- Share only `dist\OlivePackage`. -- Do not share `dist\private\OlivePrivate.pfx`. -- Do not include the Klipy API key in the package. -- Each user must set the Klipy key in Olive settings. +- + +Share +only +`dist\OlivePackage`. + +- + +Do +not +share +`dist\private\OlivePrivate.pfx`. + +- + +Do +not +include +the +Klipy +API +key +in +the +package. + +- + +Each +user +must +set +the +Klipy +key +in +Olive +settings. ## Notes -- Olive copies GIFs as files to preserve animation. -- Some apps may reject file pasting or use fallback text only. -- Cached GIFs are stored in `%LOCALAPPDATA%\Olive\Cache`. +- + +Olive +copies +GIFs +as +files +to +preserve +animation. + +- + +Some +apps +may +reject +file +pasting +or +use +fallback +text +only. + +- + +GIFs +are +downloaded +in +memory +when +copied. diff --git a/nuget.config b/nuget.config index e6a17ff..a0c3dce 100644 --- a/nuget.config +++ b/nuget.config @@ -1,12 +1,12 @@  - - - - - - - - - + + + + + + + + + diff --git a/scripts/Uninstall-Olive.ps1 b/scripts/Uninstall-Olive.ps1 index 33a989c..0dcb0f5 100644 --- a/scripts/Uninstall-Olive.ps1 +++ b/scripts/Uninstall-Olive.ps1 @@ -24,9 +24,9 @@ function Confirm-Continue { Write-Host "It will keep the trusted certificate because -KeepCertificate was used." } if ($removeUserDataNow) { - Write-Host "It will also remove Olive local settings and cached GIFs." + Write-Host "It will also remove Olive user data." } else { - Write-Host "It will keep Olive local settings and cached GIFs because -KeepUserData was used." + Write-Host "It will keep Olive user data because -KeepUserData was used." } $answer = Read-Host "Continue? Type YES" if ($answer -ne "YES") { @@ -87,7 +87,6 @@ if ($packages.Count -eq 0) { if (-not $KeepUserData) { $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) $userDataPaths = @( - (Join-Path $localAppData "Olive"), (Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Extensions\Olive"), (Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Settings\Olive"), (Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Olive") @@ -97,6 +96,12 @@ if (-not $KeepUserData) { $userDataPaths += Join-Path $localAppData "Packages\$packageFamilyName" } + $packagesRoot = Join-Path $localAppData "Packages" + if (Test-Path -LiteralPath $packagesRoot) { + $userDataPaths += Get-ChildItem -LiteralPath $packagesRoot -Directory -Filter "Olive_*" | + ForEach-Object { $_.FullName } + } + foreach ($path in ($userDataPaths | Sort-Object -Unique)) { Remove-DirectoryIfExists -Path $path }