chore: format and clean up

This commit is contained in:
JNIH
2026-07-30 20:19:01 +02:00
parent c953ca8460
commit 00deea0ca1
21 changed files with 1134 additions and 971 deletions
+14
View File
@@ -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
+11 -11
View File
@@ -3,16 +3,16 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.11.260520004" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.11.260520004"/>
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2"/>
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77"/>
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183"/>
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0"/>
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188"/>
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1"/>
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0"/>
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1"/>
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556"/>
<PackageVersion Include="System.Text.Json" Version="9.0.8"/>
</ItemGroup>
</Project>
+34 -35
View File
@@ -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();
}
}
+67
View File
@@ -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<int>
{
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<string, object> ToDictionary()
{
return new Dictionary<string, object>
{
{ "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<int>();
}
catch (InvalidOperationException)
{
var text = payload[Key]?.GetValue<string>() ?? 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}";
}
}
+63 -36
View File
@@ -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}";
}
}
+99 -85
View File
@@ -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<KlipySearchResult> SearchAsync(string apiKey, string query, int page, int perPage, CancellationToken cancellationToken)
/// <summary>
/// Searches Klipy GIFs with the raw query parameters used by Olive and returns converted GIF items plus pagination state.
/// </summary>
/// <param name="apiKey">Klipy application key used in the API route.</param>
/// <param name="query">Search text entered by the user.</param>
/// <param name="page">One-based Klipy page number to request.</param>
/// <param name="perPage">Number of items to request from Klipy for this page.</param>
/// <param name="cancellationToken">Token used to cancel the HTTP request and JSON parsing.</param>
/// <returns>Converted GIF results and whether Klipy reports another page.</returns>
public static async Task<KlipySearchResult> 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);
}
/// <summary>
/// Downloads the selected animated GIF file into memory for clipboard use.
/// </summary>
/// <param name="gifUrl">Absolute URL of the GIF file to download.</param>
/// <param name="cancellationToken">Token used to cancel the download.</param>
/// <returns>Raw GIF file bytes.</returns>
public static async Task<byte[]> 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);
}
/// <summary>
/// Builds the Klipy search endpoint URL with only page, per_page, and q query parameters.
/// </summary>
/// <param name="apiKey">Klipy application key used in the API route.</param>
/// <param name="query">Search text to pass as the q query parameter.</param>
/// <param name="page">One-based Klipy page number to request.</param>
/// <param name="perPage">Number of items to request from Klipy for this page.</param>
/// <returns>Fully escaped Klipy search URI.</returns>
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<string, string>
{
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);
}
/// <summary>
/// Converts raw Klipy response items into Olive GIF models and skips entries without usable GIF URLs.
/// </summary>
/// <param name="items">Raw item array from the Klipy search response.</param>
/// <returns>GIF models that have an ID, thumbnail URL, and full GIF URL.</returns>
private static List<KlipyGif> 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<KlipyGif>(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<string, string>
{
["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<KlipyGif> ConvertResults(KlipyItem[]? items)
{
if (items is null || items.Length == 0)
{
return [];
}
var gifs = new List<KlipyGif>(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;
}
}
+20 -19
View File
@@ -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<KlipyGif> Gifs,
bool HasMore);
IReadOnlyList<KlipyGif> 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;
+11 -11
View File
@@ -18,16 +18,16 @@
</PropertyGroup>
<ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" />
<Content Include="Assets\AppLogo150.scale-200.png" />
<Content Include="Assets\AppLogo44.scale-200.png" />
<Content Include="Assets\AppLogo44.targetsize-24_altform-unplated.png" />
<Content Include="Assets\StoreLogo.png" />
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
<Content Include="Assets\SplashScreen.scale-200.png"/>
<Content Include="Assets\AppLogo150.scale-200.png"/>
<Content Include="Assets\AppLogo44.scale-200.png"/>
<Content Include="Assets\AppLogo44.targetsize-24_altform-unplated.png"/>
<Content Include="Assets\StoreLogo.png"/>
<Content Include="Assets\Wide310x150Logo.scale-200.png"/>
</ItemGroup>
<ItemGroup>
<Manifest Include="$(ApplicationManifest)" />
<Manifest Include="$(ApplicationManifest)"/>
</ItemGroup>
<!--
@@ -36,12 +36,12 @@
package has not yet been restored.
-->
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
<ProjectCapability Include="Msix"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CommandPalette.Extensions" />
<PackageReference Include="Microsoft.Windows.CsWinRT" />
<PackageReference Include="Shmuelie.WinRTServer" />
<PackageReference Include="Microsoft.CommandPalette.Extensions"/>
<PackageReference Include="Microsoft.Windows.CsWinRT"/>
<PackageReference Include="Shmuelie.WinRTServer"/>
<!-- Needed to enable building an MSIX package -->
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.MSIX">
+20 -19
View File
@@ -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
}
];
}
}
+18 -15
View File
@@ -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();
}
}
+15 -15
View File
@@ -11,7 +11,7 @@
<Identity
Name="Olive"
Publisher="CN=OlivePrivate"
Version="0.0.32.0" />
Version="0.0.42.0"/>
<Properties>
<DisplayName>Olive</DisplayName>
<PublisherDisplayName>Private</PublisherDisplayName>
@@ -19,8 +19,8 @@
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0"/>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0"/>
</Dependencies>
<Resources>
@@ -29,35 +29,35 @@
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="Olive"
Description="Search and copy animated GIFs from Klipy. Powered by EndMove"
BackgroundColor="transparent"
Square150x150Logo="Assets\AppLogo150.png"
Square44x44Logo="Assets\AppLogo44.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png"/>
<uap:SplashScreen Image="Assets\SplashScreen.png"/>
</uap:VisualElements>
<Extensions>
<com:Extension Category="windows.comServer">
<com:ComServer>
<com:ExeServer Executable="Olive.exe" Arguments="-RegisterProcessAsComServer" DisplayName="Olive">
<com:Class Id="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A" DisplayName="Olive" />
<com:Class Id="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A" DisplayName="Olive"/>
</com:ExeServer>
</com:ComServer>
</com:Extension>
<uap3:Extension Category="windows.appExtension">
<uap3:AppExtension Name="com.microsoft.commandpalette"
Id="Olive"
PublicFolder="Public"
DisplayName="Olive"
Description="Search and copy animated GIFs from Klipy. Powered by EndMove">
Id="Olive"
PublicFolder="Public"
DisplayName="Olive"
Description="Search and copy animated GIFs from Klipy. Powered by EndMove">
<uap3:Properties>
<CmdPalProvider>
<Activation>
<CreateInstance ClassId="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A" />
<CreateInstance ClassId="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A"/>
</Activation>
<SupportedInterfaces>
<Commands/>
@@ -71,7 +71,7 @@
</Applications>
<Capabilities>
<Capability Name="internetClient" />
<rescap:Capability Name="runFullTrust" />
<Capability Name="internetClient"/>
<rescap:Capability Name="runFullTrust"/>
</Capabilities>
</Package>
+81
View File
@@ -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
};
}
}
+227 -504
View File
@@ -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<IListItem> _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<object, IItemsChangedEventArgs>? ItemsChanged;
public event TypedEventHandler<object, IPropChangedEventArgs>? 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<object, IItemsChangedEventArgs>? ItemsChanged;
var version = Interlocked.Increment(ref _searchVersion);
var cancellation = new CancellationTokenSource();
_searchCancellation = cancellation;
public event TypedEventHandler<object, IPropChangedEventArgs>? 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<KlipyGif>(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<List<KlipyGif>> SearchResultsAsync(
string apiKey,
string search,
int resultCount,
CancellationToken cancellationToken)
{
var gifs = new List<KlipyGif>(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));
}
}
+21
View File
@@ -0,0 +1,21 @@
using Microsoft.CommandPalette.Extensions;
namespace Olive.Pages;
internal sealed class GifPickerPageState
{
private readonly List<IListItem> _items = [];
public int DisplayItemCount => _items.Count;
public IListItem[] Snapshot()
{
return [.. _items];
}
public void ReplaceItems(IListItem[] items)
{
_items.Clear();
_items.AddRange(items);
}
}
+26 -26
View File
@@ -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);
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<OliveExtension, IExtension>(() => extensionInstance);
server.Start();
// 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<OliveExtension, IExtension>(() => 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);
}
// 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);
}
}
}
+71 -75
View File
@@ -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 = $"<img src=\"{dataUri}\" alt=\"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 = "<!DOCTYPE html><html><body><!--StartFragment-->";
const string afterFragment = "<!--EndFragment--></body></html>";
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 = $"<img src=\"{dataUri}\" alt=\"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 = "<!DOCTYPE html><html><body><!--StartFragment-->";
const string afterFragment = "<!--EndFragment--></body></html>";
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;
}
}
-70
View File
@@ -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<string, SemaphoreSlim> _locks = new(StringComparer.OrdinalIgnoreCase);
public GifCache()
{
_cacheDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Olive",
"Cache");
}
public async Task<string> 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();
}
+1 -1
View File
@@ -7,7 +7,7 @@
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
+312 -31
View File
@@ -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.
+9 -9
View File
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
<packageSources>
<clear/>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json"/>
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*"/>
</packageSource>
</packageSourceMapping>
</configuration>
+8 -3
View File
@@ -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
}