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>
+34 -35
View File
@@ -7,45 +7,44 @@ 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"); }
}
public override ICommandResult Invoke() public override ICommandResult Invoke()
{ {
_ = CopyAsync(); _ = CopyAsync();
return CommandResult.KeepOpen(); 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)); ShowStatus("Downloading GIF...", MessageState.Info, 800);
try var bytes = await KlipyClient.DownloadGifBytesAsync(_gif.GifUrl, cancellation.Token).ConfigureAwait(false);
{ await ClipboardService.CopyGifBytesAsync(bytes, cancellation.Token).ConfigureAwait(false);
ShowStatus("Downloading GIF...", MessageState.Info, 800); ShowStatus("GIF copied - paste with Ctrl + V", MessageState.Success, 3500);
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);
}
} }
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) private static void ShowStatus(string message, MessageState state, int duration)
{ {
new ToastStatusMessage(new StatusMessage { Message = message, State = state }) { Duration = duration }.Show(); 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; 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( public SettingsManager()
Namespaced(nameof(KlipyApiKey)), {
"Klipy API key", FilePath = SettingsJsonPath();
"Private key used to call the Klipy API. It stays stored locally in Olive settings.", Settings.Add(_klipyApiKey);
string.Empty) 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", LoadSettings();
}; return _klipyApiKey.Value ?? string.Empty;
private static string Namespaced(string propertyName) => $"{Namespace}.{propertyName}";
public string KlipyApiKey
{
get
{
LoadSettings();
return _klipyApiKey.Value ?? string.Empty;
}
} }
}
public bool HasKlipyApiKey => !string.IsNullOrWhiteSpace(KlipyApiKey); public bool HasKlipyApiKey => !string.IsNullOrWhiteSpace(KlipyApiKey);
public string SettingsPath => FilePath; public int ResultCount
{
internal static string SettingsJsonPath() get
{ {
var directory = Utilities.BaseSettingsPath("Olive"); LoadSettings();
Directory.CreateDirectory(directory); return Math.Clamp(_resultCount.Value, 30, 100);
return Path.Combine(directory, "settings.json");
} }
}
public SettingsManager() private static string SettingsJsonPath()
{ {
FilePath = SettingsJsonPath(); var directory = Utilities.BaseSettingsPath("Olive");
Directory.CreateDirectory(directory);
Settings.Add(_klipyApiKey); return Path.Combine(directory, "settings.json");
}
// Load settings from file upon initialization private static string Namespaced(string propertyName)
LoadSettings(); {
return $"{Namespace}.{propertyName}";
Settings.SettingsChanged += (_, _) => SaveSettings(); }
}
} }
+99 -85
View File
@@ -6,98 +6,112 @@ namespace Olive.Klipy;
internal sealed class KlipyClient 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))
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)) ["page"] = page.ToString(CultureInfo.InvariantCulture),
{ ["per_page"] = perPage.ToString(CultureInfo.InvariantCulture),
throw new InvalidOperationException("The Klipy API key is missing. Set it in Olive settings."); ["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); return new Uri($"{endpoint}?{queryString}");
if (response.StatusCode == HttpStatusCode.NoContent) }
{
return new KlipySearchResult([], HasMore: false);
}
if (!response.IsSuccessStatusCode) /// <summary>
{ /// Converts raw Klipy response items into Olive GIF models and skips entries without usable GIF URLs.
throw new HttpRequestException($"Klipy returned {(int)response.StatusCode} {response.ReasonPhrase}.", null, response.StatusCode); /// </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); var gifs = new List<KlipyGif>(items.Length);
if (payload is null || !payload.Result) foreach (var item in items)
{ {
throw new HttpRequestException("Klipy did not return a usable result."); 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 ?? []; if (string.IsNullOrWhiteSpace(id)
var hasMore = payload.Data?.HasNext ?? rawItems.Length >= perPage; || !Uri.TryCreate(thumbnailUrl, UriKind.Absolute, out var thumbnailGifUrl)
return new KlipySearchResult(ConvertResults(rawItems), hasMore); || !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) return gifs;
{ }
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;
}
} }
+20 -19
View File
@@ -3,39 +3,40 @@ using System.Text.Json.Serialization;
namespace Olive.Klipy; namespace Olive.Klipy;
internal sealed record KlipyGif( internal sealed record KlipyGif(
string Id, string Id,
string Title, string Title,
Uri ThumbnailGifUrl, Uri ThumbnailGifUrl,
Uri GifUrl); Uri GifUrl);
internal sealed record KlipySearchResult( internal sealed record KlipySearchResult(
IReadOnlyList<KlipyGif> Gifs, IReadOnlyList<KlipyGif> Gifs,
bool HasMore); bool HasMore);
internal sealed record KlipySearchResponse( internal sealed record KlipySearchResponse(
[property: JsonPropertyName("result")] bool Result, [property: JsonPropertyName("result")] bool Result,
[property: JsonPropertyName("data")] KlipySearchData? Data); [property: JsonPropertyName("data")] KlipySearchData? Data);
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,
[property: JsonPropertyName("slug")] string? Slug, [property: JsonPropertyName("slug")] string? Slug,
[property: JsonPropertyName("title")] string? Title, [property: JsonPropertyName("title")] string? Title,
[property: JsonPropertyName("file")] KlipyFile? File); [property: JsonPropertyName("file")] KlipyFile? File);
internal sealed record KlipyFile( internal sealed record KlipyFile(
[property: JsonPropertyName("hd")] KlipyFileSize? Hd, [property: JsonPropertyName("hd")] KlipyFileSize? Hd,
[property: JsonPropertyName("md")] KlipyFileSize? Md, [property: JsonPropertyName("md")] KlipyFileSize? Md,
[property: JsonPropertyName("sm")] KlipyFileSize? Sm); [property: JsonPropertyName("sm")] KlipyFileSize? Sm);
internal sealed record KlipyFileSize( internal sealed record KlipyFileSize(
[property: JsonPropertyName("gif")] KlipyMediaFormat? Gif); [property: JsonPropertyName("gif")] KlipyMediaFormat? Gif);
internal sealed record KlipyMediaFormat( internal sealed record KlipyMediaFormat(
[property: JsonPropertyName("url")] string? Url); [property: JsonPropertyName("url")] string? Url);
[JsonSerializable(typeof(KlipySearchResponse))] [JsonSerializable(typeof(KlipySearchResponse))]
internal sealed partial class KlipyJsonContext : JsonSerializerContext; internal sealed partial class KlipyJsonContext : JsonSerializerContext;
+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">
+20 -19
View File
@@ -7,25 +7,26 @@ namespace Olive;
public sealed partial class OliveCommandsProvider : CommandProvider public sealed partial class OliveCommandsProvider : CommandProvider
{ {
private readonly SettingsManager _settingsManager = new(); private readonly SettingsManager _settingsManager = new();
public OliveCommandsProvider() public OliveCommandsProvider()
{ {
DisplayName = "Olive"; DisplayName = "Olive";
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
Settings = _settingsManager.Settings; Settings = _settingsManager.Settings;
} }
public override ICommandItem[] TopLevelCommands() public override ICommandItem[] TopLevelCommands()
{ {
return [ return
new CommandItem(new GifPickerPage(_settingsManager)) [
{ new CommandItem(new GifPickerPage(_settingsManager))
Title = "Olive GIF Picker", {
Subtitle = "Browse through lots of GIFs and find the one that suits you best!", Title = "Olive GIF Picker",
MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)], Subtitle = "Browse through lots of GIFs and find the one that suits you best!",
Icon = Icon, MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)],
}, Icon = Icon
]; }
} ];
}
} }
+18 -15
View File
@@ -6,23 +6,26 @@ namespace Olive;
[Guid("C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A")] [Guid("C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A")]
public sealed partial class OliveExtension : IExtension, IDisposable 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) public void Dispose()
{ {
return providerType switch _extensionDisposedEvent.Set();
{ }
ProviderType.Commands => _provider,
_ => null
};
}
public void Dispose() => _extensionDisposedEvent.Set();
} }
+15 -15
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>
@@ -29,35 +29,35 @@
<Applications> <Applications>
<Application Id="App" <Application Id="App"
Executable="$targetnametoken$.exe" Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$"> EntryPoint="$targetentrypoint$">
<uap:VisualElements <uap:VisualElements
DisplayName="Olive" DisplayName="Olive"
Description="Search and copy animated GIFs from Klipy. Powered by EndMove" Description="Search and copy animated GIFs from Klipy. Powered by EndMove"
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>
<uap3:Extension Category="windows.appExtension"> <uap3:Extension Category="windows.appExtension">
<uap3:AppExtension Name="com.microsoft.commandpalette" <uap3:AppExtension Name="com.microsoft.commandpalette"
Id="Olive" Id="Olive"
PublicFolder="Public" PublicFolder="Public"
DisplayName="Olive" DisplayName="Olive"
Description="Search and copy animated GIFs from Klipy. Powered by EndMove"> Description="Search and copy animated GIFs from Klipy. Powered by EndMove">
<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
};
}
}
+227 -504
View File
@@ -1,555 +1,278 @@
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 static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500);
private const int LoadMoreBatchPages = 1;
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 GifListItemFactory _itemFactory;
private readonly List<IListItem> _items = []; private readonly GifPickerPageState _state;
private readonly IIconInfo _icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png"); private CancellationTokenSource? _searchCancellation;
private readonly IGridProperties _gridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false }; private string _searchText = string.Empty;
private CancellationTokenSource? _searchCancellation; private string _activeSearch = string.Empty;
private string _searchText = string.Empty; private int _searchVersion;
private string _activeSearch = string.Empty; private ICommandItem _emptyContent;
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;
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; if (field == value) return;
_emptyContent = InitialContent();
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"; try
{
public string Title => _title; cancellation.Cancel();
}
public OptionalColor AccentColor => default; catch (ObjectDisposedException)
public bool IsLoading
{ {
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; private async Task SearchAfterDebounceAsync(string search, int version, CancellationToken cancellationToken)
{
public bool HasMoreItems try
{ {
get => _hasMoreItems; await Task.Delay(SearchDebounceDelay, cancellationToken).ConfigureAwait(false);
private set
{
if (_hasMoreItems == value)
{
return;
}
_hasMoreItems = value; var apiKey = _settingsManager.KlipyApiKey;
RaisePropChanged(nameof(HasMoreItems)); 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; IsLoading = false;
HasMoreItems = false; EmptyContent = _itemFactory.MissingApiKeyContent();
EmptyContent = new CommandItem(new NoOpCommand()) ReplaceState([]);
{ return;
Title = "Search failed", }
Subtitle = ex.Message,
Icon = Icon,
};
if (resetItems) if (IsStale(version, cancellationToken)) return;
{
ReplaceItems([]); _activeSearch = search;
} IsLoading = true;
else EmptyContent = _itemFactory.LoadingContent(search);
{ UpdateTitle();
RaiseItemsChanged(DisplayItemCount());
} 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)
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(),
};
} }
catch (Exception ex)
private IContextItem[] CreateLoadMoreContextItems()
{ {
return [ if (version == _searchVersion) ShowSearchError(ex);
CreateLoadMoreContextItem("Load 30 more GIFs", VirtualKey.L),
];
} }
finally
private CommandContextItem CreateLoadMoreContextItem(string title, VirtualKey key)
{ {
return new CommandContextItem(new LoadMoreGifsCommand(LoadMore)) if (version == _searchVersion) IsLoading = false;
{
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() 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()) var perPage = Math.Min(KlipyMaxPageSize, resultCount - gifs.Count);
{ var result = await KlipyClient.SearchAsync(apiKey, search, page, perPage, cancellationToken)
Title = _settingsManager.HasKlipyApiKey ? "Search GIFs" : "Klipy API key missing", .ConfigureAwait(false);
Subtitle = _settingsManager.HasKlipyApiKey
? "Type something like thanks, excited, cat, or confused." gifs.AddRange(result.Gifs.Take(resultCount - gifs.Count));
: "Open Olive settings and set the Klipy API key.", if (!result.HasMore || result.Gifs.Count == 0) break;
Icon = Icon,
}; 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()) _state.ReplaceItems(items);
{ count = _state.DisplayItemCount;
Title = "Loading GIFs...",
Subtitle = $"Searching Klipy for \"{search}\"",
Icon = Icon,
};
} }
private CommandItem MissingApiKeyContent() RaiseItemsChanged(count);
UpdateTitle();
}
private int DisplayItemCount()
{
lock (_stateLock)
{ {
return new CommandItem(new NoOpCommand()) return _state.DisplayItemCount;
{
Title = "Klipy API key missing",
Subtitle = "Open Olive settings and paste your Klipy API key.",
Icon = Icon,
};
} }
}
private void ReplaceItems(IListItem[] items) private bool IsStale(int version, CancellationToken cancellationToken)
{ {
int count; return version != _searchVersion || cancellationToken.IsCancellationRequested;
lock (_itemsLock) }
{
_items.Clear();
_items.AddRange(items);
_loadedItemCount = items.Length;
AddPlaceholderItemsLocked();
count = DisplayItemCountLocked();
}
RaiseItemsChanged(count); private void UpdateTitle()
UpdateTitle(); {
} var loadedCount = DisplayItemCount();
var title = string.IsNullOrWhiteSpace(_activeSearch) || loadedCount == 0
? "Olive"
: $"Olive - {loadedCount} GIFs loaded";
private void AppendItems(IListItem[] items) if (StringComparer.Ordinal.Equals(Title, title)) return;
{
int count;
lock (_itemsLock)
{
RemovePlaceholderItemsLocked();
_items.AddRange(items);
_loadedItemCount += items.Length;
AddPlaceholderItemsLocked();
count = DisplayItemCountLocked();
}
RaiseItemsChanged(count); Title = title;
UpdateTitle(); RaisePropChanged(nameof(Title));
} }
private IListItem[] BuildDisplayItemsLocked() private void RaiseItemsChanged(int count)
{ {
return [.. _items]; ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(count));
} }
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 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 public class Program
{ {
[MTAThread] [MTAThread]
public static void Main(string[] args) 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. // 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. // 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. // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.
OliveExtension extensionInstance = new(extensionDisposedEvent); OliveExtension extensionInstance = new(extensionDisposedEvent);
server.RegisterClass<OliveExtension, IExtension>(() => extensionInstance); server.RegisterClass<OliveExtension, IExtension>(() => extensionInstance);
server.Start(); server.Start();
// This will make the main thread wait until the event is signalled by the extension class. // 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. // Since we have single instance of the extension object, we exit as soon as it is disposed.
extensionDisposedEvent.WaitOne(); extensionDisposedEvent.WaitOne();
server.Stop(); server.Stop();
server.UnsafeDispose(); 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);
}
} }
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 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); Name = "Olive clipboard STA"
ArgumentNullException.ThrowIfNull(gifUrl); };
var fullPath = Path.GetFullPath(gifPath); thread.SetApartmentState(ApartmentState.STA);
if (!File.Exists(fullPath)) thread.Start();
{
throw new FileNotFoundException("GIF file not found.", fullPath);
}
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
var thread = new Thread(() => CopyOnStaThread(fullPath, completion)); await completion.Task.ConfigureAwait(false);
}
thread.Name = "Olive clipboard STA"; private static void CopyOnStaThread(byte[] gifBytes, TaskCompletionSource completion)
thread.SetApartmentState(ApartmentState.STA); {
thread.Start(); try
cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
await completion.Task.ConfigureAwait(false);
}
private static void CopyOnStaThread(string fullPath, TaskCompletionSource completion)
{ {
var clipboardHtml = BuildClipboardHtml(gifBytes);
const int retryCount = 5;
for (var attempt = 1; attempt <= retryCount; attempt++)
try try
{ {
var gifBytes = File.ReadAllBytes(fullPath); var data = new DataObject();
var clipboardHtml = BuildClipboardHtml(gifBytes); data.SetData("image/gif", false, GifStream(gifBytes));
data.SetData("GIF", false, GifStream(gifBytes));
const int retryCount = 5; data.SetData(DataFormats.Html, false, clipboardHtml);
for (var attempt = 1; attempt <= retryCount; attempt++) data.SetData("text/html", false, clipboardHtml);
{ TryAddBitmapPreview(data, gifBytes);
try FormsClipboard.SetDataObject(data, true, 10, 100);
{ completion.TrySetResult();
var data = new DataObject(); return;
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."));
} }
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 stream = new MemoryStream(gifBytes, false);
{ using var image = Image.FromStream(stream);
using var image = Image.FromFile(fullPath); data.SetData(DataFormats.Bitmap, true, new Bitmap(image));
data.SetData(DataFormats.Bitmap, autoConvert: true, data: 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)
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;
} }
}
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. <!-- 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.
+9 -9
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
} }