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> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.11.260520004" /> <PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.11.260520004"/>
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" /> <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.Web.WebView2" Version="1.0.3719.77"/>
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" /> <PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183"/>
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" /> <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" Version="10.0.26100.4188"/>
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" /> <PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1"/>
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" /> <PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0"/>
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" /> <PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1"/>
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" /> <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556"/>
<PackageVersion Include="System.Text.Json" Version="9.0.8" /> <PackageVersion Include="System.Text.Json" Version="9.0.8"/>
</ItemGroup> </ItemGroup>
</Project> </Project>
+6 -7
View File
@@ -8,12 +8,10 @@ namespace Olive.Commands;
internal sealed partial class CopyGifCommand : InvokableCommand internal sealed partial class CopyGifCommand : InvokableCommand
{ {
private readonly KlipyGif _gif; private readonly KlipyGif _gif;
private readonly GifCache _cache;
public CopyGifCommand(KlipyGif gif, GifCache cache) public CopyGifCommand(KlipyGif gif)
{ {
_gif = gif; _gif = gif;
_cache = cache;
Name = "Copy GIF"; Name = "Copy GIF";
Icon = new IconInfo("\uE8C8"); Icon = new IconInfo("\uE8C8");
} }
@@ -21,7 +19,7 @@ internal sealed partial class CopyGifCommand : InvokableCommand
public override ICommandResult Invoke() public override ICommandResult Invoke()
{ {
_ = CopyAsync(); _ = CopyAsync();
return CommandResult.KeepOpen(); return CommandResult.Hide();
} }
private async Task CopyAsync() private async Task CopyAsync()
@@ -30,15 +28,16 @@ internal sealed partial class CopyGifCommand : InvokableCommand
try try
{ {
ShowStatus("Downloading GIF...", MessageState.Info, 800); ShowStatus("Downloading GIF...", MessageState.Info, 800);
var path = await _cache.GetOrDownloadAsync(_gif, cancellation.Token).ConfigureAwait(false); var bytes = await KlipyClient.DownloadGifBytesAsync(_gif.GifUrl, cancellation.Token).ConfigureAwait(false);
await ClipboardService.CopyGifFileAsync(path, _gif.GifUrl, _gif.Title, cancellation.Token).ConfigureAwait(false); await ClipboardService.CopyGifBytesAsync(bytes, cancellation.Token).ConfigureAwait(false);
ShowStatus("GIF copied - paste with Ctrl + V", MessageState.Success, 3500); ShowStatus("GIF copied - paste with Ctrl + V", MessageState.Success, 3500);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
ShowStatus("Copy cancelled or timed out.", MessageState.Warning, 2500); ShowStatus("Copy cancelled or timed out.", MessageState.Warning, 2500);
} }
catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidOperationException or UnauthorizedAccessException) catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidOperationException
or UnauthorizedAccessException)
{ {
ShowStatus("Could not copy this GIF.", MessageState.Error, 3500); ShowStatus("Could not copy this GIF.", MessageState.Error, 3500);
} }
+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}";
}
}
+41 -14
View File
@@ -2,20 +2,47 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Olive.Helpers; namespace Olive.Helpers;
internal sealed partial class SettingsManager : JsonSettingsManager internal sealed class SettingsManager : JsonSettingsManager
{ {
private const string Namespace = "Olive"; private const string Namespace = "Olive";
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( private readonly TextSetting _klipyApiKey = new(
Namespaced(nameof(KlipyApiKey)), Namespaced(nameof(KlipyApiKey)),
"Klipy API key", "Klipy API key",
"Private key used to call the Klipy API. It stays stored locally in Olive settings.", "Private key used to call the Klipy API. It stays stored locally in Olive settings.",
string.Empty) string.Empty)
{ {
Placeholder = "Paste your Klipy API key here", Placeholder = "Required",
IsRequired = true,
ErrorMessage = "Klipy API key is required."
}; };
private static string Namespaced(string propertyName) => $"{Namespace}.{propertyName}"; 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 public string KlipyApiKey
{ {
@@ -28,9 +55,16 @@ internal sealed partial class SettingsManager : JsonSettingsManager
public bool HasKlipyApiKey => !string.IsNullOrWhiteSpace(KlipyApiKey); public bool HasKlipyApiKey => !string.IsNullOrWhiteSpace(KlipyApiKey);
public string SettingsPath => FilePath; public int ResultCount
{
get
{
LoadSettings();
return Math.Clamp(_resultCount.Value, 30, 100);
}
}
internal static string SettingsJsonPath() private static string SettingsJsonPath()
{ {
var directory = Utilities.BaseSettingsPath("Olive"); var directory = Utilities.BaseSettingsPath("Olive");
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
@@ -38,15 +72,8 @@ internal sealed partial class SettingsManager : JsonSettingsManager
return Path.Combine(directory, "settings.json"); return Path.Combine(directory, "settings.json");
} }
public SettingsManager() private static string Namespaced(string propertyName)
{ {
FilePath = SettingsJsonPath(); return $"{Namespace}.{propertyName}";
Settings.Add(_klipyApiKey);
// Load settings from file upon initialization
LoadSettings();
Settings.SettingsChanged += (_, _) => SaveSettings();
} }
} }
+45 -31
View File
@@ -9,50 +9,64 @@ internal sealed class KlipyClient
private const string BaseUrl = "https://api.klipy.com"; private const string BaseUrl = "https://api.klipy.com";
private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(12) }; 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)) if (string.IsNullOrWhiteSpace(apiKey))
{
throw new InvalidOperationException("The Klipy API key is missing. Set it in Olive settings."); throw new InvalidOperationException("The Klipy API key is missing. Set it in Olive settings.");
}
var requestUri = BuildSearchUri(apiKey, query, page, perPage); var requestUri = BuildSearchUri(apiKey, query, page, perPage);
using var response = await HttpClient.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); using var response = await HttpClient
if (response.StatusCode == HttpStatusCode.NoContent) .GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
{ if (response.StatusCode == HttpStatusCode.NoContent) return new KlipySearchResult([], false);
return new KlipySearchResult([], HasMore: false);
}
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ throw new HttpRequestException($"Klipy returned {(int)response.StatusCode} {response.ReasonPhrase}.", null,
throw new HttpRequestException($"Klipy returned {(int)response.StatusCode} {response.ReasonPhrase}.", null, response.StatusCode); response.StatusCode);
}
var payload = await response.Content.ReadFromJsonAsync(KlipyJsonContext.Default.KlipySearchResponse, cancellationToken).ConfigureAwait(false); var payload = await response.Content
if (payload is null || !payload.Result) .ReadFromJsonAsync(KlipyJsonContext.Default.KlipySearchResponse, cancellationToken).ConfigureAwait(false);
{ if (payload is null || !payload.Result) throw new HttpRequestException("Klipy did not return a usable result.");
throw new HttpRequestException("Klipy did not return a usable result.");
}
var rawItems = payload.Data?.Items ?? []; var rawItems = payload.Data?.Items ?? [];
var hasMore = payload.Data?.HasNext ?? rawItems.Length >= perPage; var hasMore = payload.Data?.HasNext ?? rawItems.Length >= perPage;
return new KlipySearchResult(ConvertResults(rawItems), hasMore); return new KlipySearchResult(ConvertResults(rawItems), hasMore);
} }
public static async Task DownloadGifAsync(Uri gifUrl, string destinationPath, CancellationToken cancellationToken) /// <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); using var response = await HttpClient.GetAsync(gifUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Download failed ({(int)response.StatusCode}).", null, response.StatusCode); throw new HttpRequestException($"Download failed ({(int)response.StatusCode}).", null, response.StatusCode);
return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
} }
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); /// <summary>
await using var output = File.Create(destinationPath); /// Builds the Klipy search endpoint URL with only page, per_page, and q query parameters.
await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); /// </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) private static Uri BuildSearchUri(string apiKey, string query, int page, int perPage)
{ {
var endpoint = $"{BaseUrl}/api/v1/{Uri.EscapeDataString(apiKey)}/gifs/search"; var endpoint = $"{BaseUrl}/api/v1/{Uri.EscapeDataString(apiKey)}/gifs/search";
@@ -60,7 +74,7 @@ internal sealed class KlipyClient
{ {
["page"] = page.ToString(CultureInfo.InvariantCulture), ["page"] = page.ToString(CultureInfo.InvariantCulture),
["per_page"] = perPage.ToString(CultureInfo.InvariantCulture), ["per_page"] = perPage.ToString(CultureInfo.InvariantCulture),
["q"] = query, ["q"] = query
}; };
var queryString = string.Join("&", parameters.Select(pair => string.Create( var queryString = string.Join("&", parameters.Select(pair => string.Create(
@@ -70,12 +84,14 @@ internal sealed class KlipyClient
return new Uri($"{endpoint}?{queryString}"); return new Uri($"{endpoint}?{queryString}");
} }
/// <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) private static List<KlipyGif> ConvertResults(KlipyItem[]? items)
{ {
if (items is null || items.Length == 0) if (items is null || items.Length == 0) return [];
{
return [];
}
var gifs = new List<KlipyGif>(items.Length); var gifs = new List<KlipyGif>(items.Length);
foreach (var item in items) foreach (var item in items)
@@ -87,9 +103,7 @@ internal sealed class KlipyClient
if (string.IsNullOrWhiteSpace(id) if (string.IsNullOrWhiteSpace(id)
|| !Uri.TryCreate(thumbnailUrl, UriKind.Absolute, out var thumbnailGifUrl) || !Uri.TryCreate(thumbnailUrl, UriKind.Absolute, out var thumbnailGifUrl)
|| !Uri.TryCreate(fullUrl, UriKind.Absolute, out var gifUrl)) || !Uri.TryCreate(fullUrl, UriKind.Absolute, out var gifUrl))
{
continue; continue;
}
var title = string.IsNullOrWhiteSpace(item.Title) var title = string.IsNullOrWhiteSpace(item.Title)
? "GIF Klipy" ? "GIF Klipy"
+2 -1
View File
@@ -18,7 +18,8 @@ internal sealed record KlipySearchResponse(
internal sealed record KlipySearchData( internal sealed record KlipySearchData(
[property: JsonPropertyName("data")] KlipyItem[]? Items, [property: JsonPropertyName("data")] KlipyItem[]? Items,
[property: JsonPropertyName("has_next")] bool? HasNext); [property: JsonPropertyName("has_next")]
bool? HasNext);
internal sealed record KlipyItem( internal sealed record KlipyItem(
[property: JsonPropertyName("id")] long? Id, [property: JsonPropertyName("id")] long? Id,
+11 -11
View File
@@ -18,16 +18,16 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" /> <Content Include="Assets\SplashScreen.scale-200.png"/>
<Content Include="Assets\AppLogo150.scale-200.png" /> <Content Include="Assets\AppLogo150.scale-200.png"/>
<Content Include="Assets\AppLogo44.scale-200.png" /> <Content Include="Assets\AppLogo44.scale-200.png"/>
<Content Include="Assets\AppLogo44.targetsize-24_altform-unplated.png" /> <Content Include="Assets\AppLogo44.targetsize-24_altform-unplated.png"/>
<Content Include="Assets\StoreLogo.png" /> <Content Include="Assets\StoreLogo.png"/>
<Content Include="Assets\Wide310x150Logo.scale-200.png" /> <Content Include="Assets\Wide310x150Logo.scale-200.png"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Manifest Include="$(ApplicationManifest)" /> <Manifest Include="$(ApplicationManifest)"/>
</ItemGroup> </ItemGroup>
<!-- <!--
@@ -36,12 +36,12 @@
package has not yet been restored. package has not yet been restored.
--> -->
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'"> <ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" /> <ProjectCapability Include="Msix"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.CommandPalette.Extensions" /> <PackageReference Include="Microsoft.CommandPalette.Extensions"/>
<PackageReference Include="Microsoft.Windows.CsWinRT" /> <PackageReference Include="Microsoft.Windows.CsWinRT"/>
<PackageReference Include="Shmuelie.WinRTServer" /> <PackageReference Include="Shmuelie.WinRTServer"/>
<!-- Needed to enable building an MSIX package --> <!-- Needed to enable building an MSIX package -->
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.MSIX"> <PackageReference Include="Microsoft.Windows.SDK.BuildTools.MSIX">
+4 -3
View File
@@ -18,14 +18,15 @@ public sealed partial class OliveCommandsProvider : CommandProvider
public override ICommandItem[] TopLevelCommands() public override ICommandItem[] TopLevelCommands()
{ {
return [ return
[
new CommandItem(new GifPickerPage(_settingsManager)) new CommandItem(new GifPickerPage(_settingsManager))
{ {
Title = "Olive GIF Picker", Title = "Olive GIF Picker",
Subtitle = "Browse through lots of GIFs and find the one that suits you best!", Subtitle = "Browse through lots of GIFs and find the one that suits you best!",
MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)], MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)],
Icon = Icon, Icon = Icon
}, }
]; ];
} }
} }
+4 -1
View File
@@ -24,5 +24,8 @@ public sealed partial class OliveExtension : IExtension, IDisposable
}; };
} }
public void Dispose() => _extensionDisposedEvent.Set(); public void Dispose()
{
_extensionDisposedEvent.Set();
}
} }
+9 -9
View File
@@ -11,7 +11,7 @@
<Identity <Identity
Name="Olive" Name="Olive"
Publisher="CN=OlivePrivate" Publisher="CN=OlivePrivate"
Version="0.0.32.0" /> Version="0.0.42.0"/>
<Properties> <Properties>
<DisplayName>Olive</DisplayName> <DisplayName>Olive</DisplayName>
<PublisherDisplayName>Private</PublisherDisplayName> <PublisherDisplayName>Private</PublisherDisplayName>
@@ -19,8 +19,8 @@
</Properties> </Properties>
<Dependencies> <Dependencies>
<TargetDeviceFamily Name="Windows.Universal" 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" /> <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0"/>
</Dependencies> </Dependencies>
<Resources> <Resources>
@@ -37,14 +37,14 @@
BackgroundColor="transparent" BackgroundColor="transparent"
Square150x150Logo="Assets\AppLogo150.png" Square150x150Logo="Assets\AppLogo150.png"
Square44x44Logo="Assets\AppLogo44.png"> Square44x44Logo="Assets\AppLogo44.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" /> <uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png"/>
<uap:SplashScreen Image="Assets\SplashScreen.png" /> <uap:SplashScreen Image="Assets\SplashScreen.png"/>
</uap:VisualElements> </uap:VisualElements>
<Extensions> <Extensions>
<com:Extension Category="windows.comServer"> <com:Extension Category="windows.comServer">
<com:ComServer> <com:ComServer>
<com:ExeServer Executable="Olive.exe" Arguments="-RegisterProcessAsComServer" DisplayName="Olive"> <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:ExeServer>
</com:ComServer> </com:ComServer>
</com:Extension> </com:Extension>
@@ -57,7 +57,7 @@
<uap3:Properties> <uap3:Properties>
<CmdPalProvider> <CmdPalProvider>
<Activation> <Activation>
<CreateInstance ClassId="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A" /> <CreateInstance ClassId="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A"/>
</Activation> </Activation>
<SupportedInterfaces> <SupportedInterfaces>
<Commands/> <Commands/>
@@ -71,7 +71,7 @@
</Applications> </Applications>
<Capabilities> <Capabilities>
<Capability Name="internetClient" /> <Capability Name="internetClient"/>
<rescap:Capability Name="runFullTrust" /> <rescap:Capability Name="runFullTrust"/>
</Capabilities> </Capabilities>
</Package> </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
};
}
}
+98 -375
View File
@@ -1,71 +1,56 @@
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
using Olive.Commands;
using Olive.Helpers; using Olive.Helpers;
using Olive.Klipy; using Olive.Klipy;
using Olive.Services;
using Windows.Foundation; using Windows.Foundation;
using Windows.System;
namespace Olive.Pages; namespace Olive.Pages;
internal sealed partial class GifPickerPage : IDynamicListPage internal sealed partial class GifPickerPage : IDynamicListPage
{ {
private const int PageSize = 30; private const int KlipyMaxPageSize = 50;
private const int InitialBatchPages = 1;
private const int LoadMoreBatchPages = 1;
private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500); private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500);
private readonly Lock _itemsLock = new(); private readonly Lock _stateLock = new();
private readonly GifCache _cache = new();
private readonly SettingsManager _settingsManager; private readonly SettingsManager _settingsManager;
private readonly List<IListItem> _items = []; private readonly GifListItemFactory _itemFactory;
private readonly IIconInfo _icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); private readonly GifPickerPageState _state;
private readonly IGridProperties _gridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false };
private CancellationTokenSource? _searchCancellation; private CancellationTokenSource? _searchCancellation;
private string _searchText = string.Empty; private string _searchText = string.Empty;
private string _activeSearch = string.Empty; private string _activeSearch = string.Empty;
private string _title = "Olive";
private int _searchVersion; 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 ICommandItem _emptyContent;
public GifPickerPage(SettingsManager settingsManager) public GifPickerPage(SettingsManager settingsManager)
{ {
_settingsManager = settingsManager; _settingsManager = settingsManager;
_emptyContent = InitialContent(); _itemFactory = new GifListItemFactory(Icon, _settingsManager);
_state = new GifPickerPageState();
_emptyContent = _itemFactory.InitialContent();
} }
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged; public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged; public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
public IIconInfo Icon => _icon; public IIconInfo Icon { get; } = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
public string Id => string.Empty; public string Id => string.Empty;
public string Name => "Olive GIF Picker"; public string Name => "Olive GIF Picker";
public string Title => _title; public string Title { get; private set; } = "Olive";
public OptionalColor AccentColor => default; public OptionalColor AccentColor => default;
public bool IsLoading public bool IsLoading
{ {
get => _isLoading; get;
private set private set
{ {
if (_isLoading == value) if (field == value) return;
{
return;
}
_isLoading = value; field = value;
RaisePropChanged(nameof(IsLoading)); RaisePropChanged(nameof(IsLoading));
} }
} }
@@ -74,24 +59,11 @@ internal sealed partial class GifPickerPage : IDynamicListPage
public bool ShowDetails => false; public bool ShowDetails => false;
public bool HasMoreItems public bool HasMoreItems => false;
{
get => _hasMoreItems;
private set
{
if (_hasMoreItems == value)
{
return;
}
_hasMoreItems = value;
RaisePropChanged(nameof(HasMoreItems));
}
}
public IFilters? Filters => null; public IFilters? Filters => null;
public IGridProperties GridProperties => _gridProperties; public IGridProperties GridProperties { get; } = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false };
public ICommandItem EmptyContent public ICommandItem EmptyContent
{ {
@@ -109,10 +81,7 @@ internal sealed partial class GifPickerPage : IDynamicListPage
set set
{ {
value ??= string.Empty; value ??= string.Empty;
if (StringComparer.Ordinal.Equals(_searchText, value)) if (StringComparer.Ordinal.Equals(_searchText, value)) return;
{
return;
}
_searchText = value; _searchText = value;
UpdateSearchText(value); UpdateSearchText(value);
@@ -121,30 +90,14 @@ internal sealed partial class GifPickerPage : IDynamicListPage
public IListItem[] GetItems() public IListItem[] GetItems()
{ {
lock (_itemsLock) lock (_stateLock)
{ {
return BuildDisplayItemsLocked(); return _state.Snapshot();
} }
} }
public void LoadMore() 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) private void UpdateSearchText(string newSearch)
@@ -154,35 +107,33 @@ internal sealed partial class GifPickerPage : IDynamicListPage
var trimmedSearch = newSearch.Trim(); var trimmedSearch = newSearch.Trim();
if (string.IsNullOrWhiteSpace(trimmedSearch)) if (string.IsNullOrWhiteSpace(trimmedSearch))
{ {
Interlocked.Increment(ref _searchVersion); ResetSearchState();
Interlocked.Increment(ref _loadRequestId); EmptyContent = _settingsManager.HasKlipyApiKey
IsLoading = false; ? _itemFactory.InitialContent()
_isLoadingMore = false; : _itemFactory.MissingApiKeyContent();
HasMoreItems = false;
_activeSearch = string.Empty;
_nextPage = 1;
ReplaceItems([]);
UpdateTitle();
EmptyContent = _settingsManager.HasKlipyApiKey ? InitialContent() : MissingApiKeyContent();
return; return;
} }
var version = Interlocked.Increment(ref _searchVersion); var version = Interlocked.Increment(ref _searchVersion);
Interlocked.Increment(ref _loadRequestId);
var cancellation = new CancellationTokenSource(); var cancellation = new CancellationTokenSource();
_searchCancellation = cancellation; _searchCancellation = cancellation;
_ = SearchAfterDebounceAsync(trimmedSearch, version, cancellation.Token); _ = SearchAfterDebounceAsync(trimmedSearch, version, cancellation.Token);
} }
private void ResetSearchState()
{
Interlocked.Increment(ref _searchVersion);
IsLoading = false;
_activeSearch = string.Empty;
ReplaceState([]);
}
private void CancelSearch() private void CancelSearch()
{ {
var cancellation = _searchCancellation; var cancellation = _searchCancellation;
_searchCancellation = null; _searchCancellation = null;
if (cancellation is null) if (cancellation is null) return;
{
return;
}
try try
{ {
@@ -204,298 +155,115 @@ internal sealed partial class GifPickerPage : IDynamicListPage
var apiKey = _settingsManager.KlipyApiKey; var apiKey = _settingsManager.KlipyApiKey;
if (string.IsNullOrWhiteSpace(apiKey)) if (string.IsNullOrWhiteSpace(apiKey))
{ {
if (version != _searchVersion || cancellationToken.IsCancellationRequested) if (IsStale(version, cancellationToken)) return;
{
return;
}
IsLoading = false; IsLoading = false;
HasMoreItems = false; EmptyContent = _itemFactory.MissingApiKeyContent();
EmptyContent = MissingApiKeyContent(); ReplaceState([]);
ReplaceItems([]);
return; return;
} }
if (version != _searchVersion || cancellationToken.IsCancellationRequested) if (IsStale(version, cancellationToken)) return;
{
return;
}
_activeSearch = search; _activeSearch = search;
_nextPage = 1;
_isLoadingMore = false;
HasMoreItems = false;
IsLoading = true; IsLoading = true;
EmptyContent = LoadingContent(search); EmptyContent = _itemFactory.LoadingContent(search);
UpdateTitle(); UpdateTitle();
await LoadPageBatchAsync(apiKey, search, version, InitialBatchPages, cancellationToken).ConfigureAwait(false); 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());
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
} }
catch (Exception ex) catch (Exception ex)
{ {
if (version == _searchVersion) if (version == _searchVersion) ShowSearchError(ex);
{
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 finally
{ {
if (loadRequestId == _loadRequestId) if (version == _searchVersion) IsLoading = false;
{ }
_isLoadingMore = false;
} }
if (version == _searchVersion && loadRequestId == _loadRequestId) 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)
{
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++;
}
return gifs;
}
private void ShowSearchError(Exception ex)
{ {
IsLoading = false; IsLoading = false;
} EmptyContent = _itemFactory.SearchErrorContent(ex);
} ReplaceState([]);
} }
private void ShowSearchError(Exception ex, bool resetItems) private void ReplaceState(IListItem[] items)
{
IsLoading = false;
HasMoreItems = false;
EmptyContent = new CommandItem(new NoOpCommand())
{
Title = "Search failed",
Subtitle = ex.Message,
Icon = Icon,
};
if (resetItems)
{
ReplaceItems([]);
}
else
{
RaiseItemsChanged(DisplayItemCount());
}
}
private ListItem CreateItem(KlipyGif gif)
{
return new ListItem(new CopyGifCommand(gif, _cache))
{
Title = string.Empty,
Subtitle = string.Empty,
Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()),
MoreCommands = CreateLoadMoreContextItems(),
};
}
private IContextItem[] CreateLoadMoreContextItems()
{
return [
CreateLoadMoreContextItem("Load 30 more GIFs", VirtualKey.L),
];
}
private CommandContextItem CreateLoadMoreContextItem(string title, VirtualKey key)
{
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),
};
}
private 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,
};
}
private CommandItem LoadingContent(string search)
{
return new CommandItem(new NoOpCommand())
{
Title = "Loading GIFs...",
Subtitle = $"Searching Klipy for \"{search}\"",
Icon = Icon,
};
}
private CommandItem MissingApiKeyContent()
{
return new CommandItem(new NoOpCommand())
{
Title = "Klipy API key missing",
Subtitle = "Open Olive settings and paste your Klipy API key.",
Icon = Icon,
};
}
private void ReplaceItems(IListItem[] items)
{ {
int count; int count;
lock (_itemsLock) lock (_stateLock)
{ {
_items.Clear(); _state.ReplaceItems(items);
_items.AddRange(items); count = _state.DisplayItemCount;
_loadedItemCount = items.Length;
AddPlaceholderItemsLocked();
count = DisplayItemCountLocked();
} }
RaiseItemsChanged(count); RaiseItemsChanged(count);
UpdateTitle(); UpdateTitle();
} }
private void AppendItems(IListItem[] items)
{
int count;
lock (_itemsLock)
{
RemovePlaceholderItemsLocked();
_items.AddRange(items);
_loadedItemCount += items.Length;
AddPlaceholderItemsLocked();
count = DisplayItemCountLocked();
}
RaiseItemsChanged(count);
UpdateTitle();
}
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() private int DisplayItemCount()
{ {
lock (_itemsLock) lock (_stateLock)
{ {
return DisplayItemCountLocked(); return _state.DisplayItemCount;
} }
} }
private int DisplayItemCountLocked() private bool IsStale(int version, CancellationToken cancellationToken)
{ {
return _items.Count; return version != _searchVersion || cancellationToken.IsCancellationRequested;
}
private void UpdateTitle()
{
var loadedCount = DisplayItemCount();
var title = string.IsNullOrWhiteSpace(_activeSearch) || loadedCount == 0
? "Olive"
: $"Olive - {loadedCount} GIFs loaded";
if (StringComparer.Ordinal.Equals(Title, title)) return;
Title = title;
RaisePropChanged(nameof(Title));
} }
private void RaiseItemsChanged(int count) private void RaiseItemsChanged(int count)
@@ -507,49 +275,4 @@ internal sealed partial class GifPickerPage : IDynamicListPage
{ {
PropChanged?.Invoke(this, new PropChangedEventArgs(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();
}
}
} }
+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);
}
}
+23 -27
View File
@@ -11,21 +11,17 @@ namespace Olive.Services;
internal sealed class ClipboardService 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)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(gifPath); ArgumentNullException.ThrowIfNull(gifBytes);
ArgumentNullException.ThrowIfNull(gifUrl); if (gifBytes.Length == 0) throw new InvalidOperationException("GIF data is empty.");
var fullPath = Path.GetFullPath(gifPath);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException("GIF file not found.", fullPath);
}
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() => CopyOnStaThread(fullPath, completion)); var thread = new Thread(() => CopyOnStaThread(gifBytes, completion))
{
Name = "Olive clipboard STA"
};
thread.Name = "Olive clipboard STA";
thread.SetApartmentState(ApartmentState.STA); thread.SetApartmentState(ApartmentState.STA);
thread.Start(); thread.Start();
@@ -33,25 +29,23 @@ internal sealed class ClipboardService
await completion.Task.ConfigureAwait(false); await completion.Task.ConfigureAwait(false);
} }
private static void CopyOnStaThread(string fullPath, TaskCompletionSource completion) private static void CopyOnStaThread(byte[] gifBytes, TaskCompletionSource completion)
{ {
try try
{ {
var gifBytes = File.ReadAllBytes(fullPath);
var clipboardHtml = BuildClipboardHtml(gifBytes); var clipboardHtml = BuildClipboardHtml(gifBytes);
const int retryCount = 5; const int retryCount = 5;
for (var attempt = 1; attempt <= retryCount; attempt++) for (var attempt = 1; attempt <= retryCount; attempt++)
{
try try
{ {
var data = new DataObject(); var data = new DataObject();
data.SetData("image/gif", autoConvert: false, data: GifStream(gifBytes)); data.SetData("image/gif", false, GifStream(gifBytes));
data.SetData("GIF", autoConvert: false, data: GifStream(gifBytes)); data.SetData("GIF", false, GifStream(gifBytes));
data.SetData(DataFormats.Html, autoConvert: false, data: clipboardHtml); data.SetData(DataFormats.Html, false, clipboardHtml);
data.SetData("text/html", autoConvert: false, data: clipboardHtml); data.SetData("text/html", false, clipboardHtml);
TryAddBitmapPreview(data, fullPath); TryAddBitmapPreview(data, gifBytes);
FormsClipboard.SetDataObject(data, copy: true, retryTimes: 10, retryDelay: 100); FormsClipboard.SetDataObject(data, true, 10, 100);
completion.TrySetResult(); completion.TrySetResult();
return; return;
} }
@@ -59,7 +53,6 @@ internal sealed class ClipboardService
{ {
Thread.Sleep(120); Thread.Sleep(120);
} }
}
completion.TrySetException(new InvalidOperationException("The clipboard is temporarily unavailable.")); completion.TrySetException(new InvalidOperationException("The clipboard is temporarily unavailable."));
} }
@@ -71,15 +64,16 @@ internal sealed class ClipboardService
private static MemoryStream GifStream(byte[] gifBytes) private static MemoryStream GifStream(byte[] gifBytes)
{ {
return new MemoryStream(gifBytes, writable: false); return new MemoryStream(gifBytes, false);
} }
private static void TryAddBitmapPreview(DataObject data, string fullPath) private static void TryAddBitmapPreview(DataObject data, byte[] gifBytes)
{ {
try try
{ {
using var image = Image.FromFile(fullPath); using var stream = new MemoryStream(gifBytes, false);
data.SetData(DataFormats.Bitmap, autoConvert: true, data: new Bitmap(image)); using var image = Image.FromStream(stream);
data.SetData(DataFormats.Bitmap, true, new Bitmap(image));
} }
catch (Exception ex) when (ex is ArgumentException or ExternalException or OutOfMemoryException) catch (Exception ex) when (ex is ArgumentException or ExternalException or OutOfMemoryException)
{ {
@@ -90,7 +84,8 @@ internal sealed class ClipboardService
{ {
var dataUri = "data:image/gif;base64," + Convert.ToBase64String(gifBytes); var dataUri = "data:image/gif;base64," + Convert.ToBase64String(gifBytes);
var fragment = $"<img src=\"{dataUri}\" alt=\"GIF\">"; 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"; 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); var prefix = string.Format(CultureInfo.InvariantCulture, markerPrefix, 0, 0, 0, 0);
const string beforeFragment = "<!DOCTYPE html><html><body><!--StartFragment-->"; const string beforeFragment = "<!DOCTYPE html><html><body><!--StartFragment-->";
const string afterFragment = "<!--EndFragment--></body></html>"; const string afterFragment = "<!--EndFragment--></body></html>";
@@ -101,7 +96,8 @@ internal sealed class ClipboardService
var endFragment = startFragment + Encoding.UTF8.GetByteCount(fragment); var endFragment = startFragment + Encoding.UTF8.GetByteCount(fragment);
var endHtml = startHtml + Encoding.UTF8.GetByteCount(html); var endHtml = startHtml + Encoding.UTF8.GetByteCount(html);
var header = string.Format(CultureInfo.InvariantCulture, markerPrefix, startHtml, endHtml, startFragment, endFragment); var header = string.Format(CultureInfo.InvariantCulture, markerPrefix, startHtml, endHtml, startFragment,
endFragment);
return header + html; 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. <!-- 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. 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 --> 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> </application>
</compatibility> </compatibility>
+312 -31
View File
@@ -1,19 +1,120 @@
# Olive # 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 ## Requirements
- Windows 10/11 with PowerToys and Command Palette enabled. -
- .NET SDK 10.
- Windows SDK `10.0.22621.0` or a compatible newer SDK. Windows
- A Klipy API key. 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 ## 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 ## Build
@@ -27,71 +128,251 @@ dotnet build .\Olive.sln -c Debug -p:Platform=x64
.\scripts\Build-OlivePackage.ps1 .\scripts\Build-OlivePackage.ps1
``` ```
Or double-click: Or
double-click:
```text ```text
scripts\Build-OlivePackage.cmd 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 ## Install
From `dist\OlivePackage`: From
`dist\OlivePackage`:
```powershell ```powershell
.\Install-Olive.ps1 .\Install-Olive.ps1
``` ```
Or double-click: Or
double-click:
```text ```text
Install-Olive.cmd 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 ## Uninstall
From `dist\OlivePackage` or `scripts\`: From
`dist\OlivePackage`
or
`scripts\`:
```powershell ```powershell
.\Uninstall-Olive.ps1 .\Uninstall-Olive.ps1
``` ```
Or double-click: Or
double-click:
```text ```text
Uninstall-Olive.cmd 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 ## Usage
1. Open PowerToys Command Palette. 1.
2. Launch `Olive GIF Picker`.
3. Search for a GIF. Open
4. Select a result with Enter. PowerToys
5. Paste with Ctrl+V in an app that accepts animated GIF files. 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 ## Sharing
- Share only `dist\OlivePackage`. -
- Do not share `dist\private\OlivePrivate.pfx`.
- Do not include the Klipy API key in the package. Share
- Each user must set the Klipy key in Olive settings. 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 ## 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.
+3 -3
View File
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear/>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" /> <add key="nuget.org" value="https://api.nuget.org/v3/index.json"/>
</packageSources> </packageSources>
<packageSourceMapping> <packageSourceMapping>
<packageSource key="nuget.org"> <packageSource key="nuget.org">
<package pattern="*" /> <package pattern="*"/>
</packageSource> </packageSource>
</packageSourceMapping> </packageSourceMapping>
</configuration> </configuration>
+8 -3
View File
@@ -24,9 +24,9 @@ function Confirm-Continue {
Write-Host "It will keep the trusted certificate because -KeepCertificate was used." Write-Host "It will keep the trusted certificate because -KeepCertificate was used."
} }
if ($removeUserDataNow) { if ($removeUserDataNow) {
Write-Host "It will also remove Olive local settings and cached GIFs." Write-Host "It will also remove Olive user data."
} else { } 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" $answer = Read-Host "Continue? Type YES"
if ($answer -ne "YES") { if ($answer -ne "YES") {
@@ -87,7 +87,6 @@ if ($packages.Count -eq 0) {
if (-not $KeepUserData) { if (-not $KeepUserData) {
$localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
$userDataPaths = @( $userDataPaths = @(
(Join-Path $localAppData "Olive"),
(Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Extensions\Olive"), (Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Extensions\Olive"),
(Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Settings\Olive"), (Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Settings\Olive"),
(Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Olive") (Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Olive")
@@ -97,6 +96,12 @@ if (-not $KeepUserData) {
$userDataPaths += Join-Path $localAppData "Packages\$packageFamilyName" $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)) { foreach ($path in ($userDataPaths | Sort-Object -Unique)) {
Remove-DirectoryIfExists -Path $path Remove-DirectoryIfExists -Path $path
} }