chore: format and clean up
This commit is contained in:
@@ -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
@@ -3,16 +3,16 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.11.260520004" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
|
||||
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.11.260520004"/>
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2"/>
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77"/>
|
||||
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183"/>
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0"/>
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188"/>
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1"/>
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0"/>
|
||||
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1"/>
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556"/>
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.8"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,45 +7,44 @@ namespace Olive.Commands;
|
||||
|
||||
internal sealed partial class CopyGifCommand : InvokableCommand
|
||||
{
|
||||
private readonly KlipyGif _gif;
|
||||
private readonly GifCache _cache;
|
||||
private readonly KlipyGif _gif;
|
||||
|
||||
public CopyGifCommand(KlipyGif gif, GifCache cache)
|
||||
{
|
||||
_gif = gif;
|
||||
_cache = cache;
|
||||
Name = "Copy GIF";
|
||||
Icon = new IconInfo("\uE8C8");
|
||||
}
|
||||
public CopyGifCommand(KlipyGif gif)
|
||||
{
|
||||
_gif = gif;
|
||||
Name = "Copy GIF";
|
||||
Icon = new IconInfo("\uE8C8");
|
||||
}
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
_ = CopyAsync();
|
||||
return CommandResult.KeepOpen();
|
||||
}
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
_ = CopyAsync();
|
||||
return CommandResult.Hide();
|
||||
}
|
||||
|
||||
private async Task CopyAsync()
|
||||
private async Task CopyAsync()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
ShowStatus("Downloading GIF...", MessageState.Info, 800);
|
||||
var path = await _cache.GetOrDownloadAsync(_gif, cancellation.Token).ConfigureAwait(false);
|
||||
await ClipboardService.CopyGifFileAsync(path, _gif.GifUrl, _gif.Title, cancellation.Token).ConfigureAwait(false);
|
||||
ShowStatus("GIF copied - paste with Ctrl + V", MessageState.Success, 3500);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ShowStatus("Copy cancelled or timed out.", MessageState.Warning, 2500);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidOperationException or UnauthorizedAccessException)
|
||||
{
|
||||
ShowStatus("Could not copy this GIF.", MessageState.Error, 3500);
|
||||
}
|
||||
ShowStatus("Downloading GIF...", MessageState.Info, 800);
|
||||
var bytes = await KlipyClient.DownloadGifBytesAsync(_gif.GifUrl, cancellation.Token).ConfigureAwait(false);
|
||||
await ClipboardService.CopyGifBytesAsync(bytes, cancellation.Token).ConfigureAwait(false);
|
||||
ShowStatus("GIF copied - paste with Ctrl + V", MessageState.Success, 3500);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
ShowStatus("Copy cancelled or timed out.", MessageState.Warning, 2500);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidOperationException
|
||||
or UnauthorizedAccessException)
|
||||
{
|
||||
ShowStatus("Could not copy this GIF.", MessageState.Error, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowStatus(string message, MessageState state, int duration)
|
||||
{
|
||||
new ToastStatusMessage(new StatusMessage { Message = message, State = state }) { Duration = duration }.Show();
|
||||
}
|
||||
private static void ShowStatus(string message, MessageState state, int duration)
|
||||
{
|
||||
new ToastStatusMessage(new StatusMessage { Message = message, State = state }) { Duration = duration }.Show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -2,51 +2,78 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Olive.Helpers;
|
||||
|
||||
internal sealed partial class SettingsManager : JsonSettingsManager
|
||||
internal sealed class SettingsManager : JsonSettingsManager
|
||||
{
|
||||
private const string Namespace = "Olive";
|
||||
private const string Namespace = "Olive";
|
||||
|
||||
private readonly TextSetting _klipyApiKey = new(
|
||||
Namespaced(nameof(KlipyApiKey)),
|
||||
"Klipy API key",
|
||||
"Private key used to call the Klipy API. It stays stored locally in Olive settings.",
|
||||
string.Empty)
|
||||
public SettingsManager()
|
||||
{
|
||||
FilePath = SettingsJsonPath();
|
||||
Settings.Add(_klipyApiKey);
|
||||
Settings.Add(_resultCount);
|
||||
|
||||
// Load settings from file upon initialization
|
||||
LoadSettings();
|
||||
|
||||
Settings.SettingsChanged += (_, _) => SaveSettings();
|
||||
}
|
||||
|
||||
public string SettingsPath => FilePath;
|
||||
|
||||
private readonly TextSetting _klipyApiKey = new(
|
||||
Namespaced(nameof(KlipyApiKey)),
|
||||
"Klipy API key",
|
||||
"Private key used to call the Klipy API. It stays stored locally in Olive settings.",
|
||||
string.Empty)
|
||||
{
|
||||
Placeholder = "Required",
|
||||
IsRequired = true,
|
||||
ErrorMessage = "Klipy API key is required."
|
||||
};
|
||||
|
||||
private readonly IntegerSetting _resultCount = new(
|
||||
Namespaced(nameof(ResultCount)),
|
||||
"Result count",
|
||||
"Number of GIF results to load for each search.",
|
||||
50,
|
||||
20,
|
||||
100)
|
||||
{
|
||||
Placeholder = "Min 20, max 100",
|
||||
IsRequired = true,
|
||||
ErrorMessage = "Enter a number between 20 and 100."
|
||||
};
|
||||
|
||||
public string KlipyApiKey
|
||||
{
|
||||
get
|
||||
{
|
||||
Placeholder = "Paste your Klipy API key here",
|
||||
};
|
||||
|
||||
private static string Namespaced(string propertyName) => $"{Namespace}.{propertyName}";
|
||||
|
||||
public string KlipyApiKey
|
||||
{
|
||||
get
|
||||
{
|
||||
LoadSettings();
|
||||
return _klipyApiKey.Value ?? string.Empty;
|
||||
}
|
||||
LoadSettings();
|
||||
return _klipyApiKey.Value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasKlipyApiKey => !string.IsNullOrWhiteSpace(KlipyApiKey);
|
||||
public bool HasKlipyApiKey => !string.IsNullOrWhiteSpace(KlipyApiKey);
|
||||
|
||||
public string SettingsPath => FilePath;
|
||||
|
||||
internal static string SettingsJsonPath()
|
||||
public int ResultCount
|
||||
{
|
||||
get
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Olive");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
return Path.Combine(directory, "settings.json");
|
||||
LoadSettings();
|
||||
return Math.Clamp(_resultCount.Value, 30, 100);
|
||||
}
|
||||
}
|
||||
|
||||
public SettingsManager()
|
||||
{
|
||||
FilePath = SettingsJsonPath();
|
||||
private static string SettingsJsonPath()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Olive");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
Settings.Add(_klipyApiKey);
|
||||
return Path.Combine(directory, "settings.json");
|
||||
}
|
||||
|
||||
// Load settings from file upon initialization
|
||||
LoadSettings();
|
||||
|
||||
Settings.SettingsChanged += (_, _) => SaveSettings();
|
||||
}
|
||||
private static string Namespaced(string propertyName)
|
||||
{
|
||||
return $"{Namespace}.{propertyName}";
|
||||
}
|
||||
}
|
||||
|
||||
+99
-85
@@ -6,98 +6,112 @@ namespace Olive.Klipy;
|
||||
|
||||
internal sealed class KlipyClient
|
||||
{
|
||||
private const string BaseUrl = "https://api.klipy.com";
|
||||
private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(12) };
|
||||
private const string BaseUrl = "https://api.klipy.com";
|
||||
private static readonly HttpClient HttpClient = new() { Timeout = TimeSpan.FromSeconds(12) };
|
||||
|
||||
public static async Task<KlipySearchResult> SearchAsync(string apiKey, string query, int page, int perPage, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Searches Klipy GIFs with the raw query parameters used by Olive and returns converted GIF items plus pagination state.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">Klipy application key used in the API route.</param>
|
||||
/// <param name="query">Search text entered by the user.</param>
|
||||
/// <param name="page">One-based Klipy page number to request.</param>
|
||||
/// <param name="perPage">Number of items to request from Klipy for this page.</param>
|
||||
/// <param name="cancellationToken">Token used to cancel the HTTP request and JSON parsing.</param>
|
||||
/// <returns>Converted GIF results and whether Klipy reports another page.</returns>
|
||||
public static async Task<KlipySearchResult> SearchAsync(string apiKey, string query, int page, int perPage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
throw new InvalidOperationException("The Klipy API key is missing. Set it in Olive settings.");
|
||||
|
||||
var requestUri = BuildSearchUri(apiKey, query, page, perPage);
|
||||
|
||||
using var response = await HttpClient
|
||||
.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NoContent) return new KlipySearchResult([], false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"Klipy returned {(int)response.StatusCode} {response.ReasonPhrase}.", null,
|
||||
response.StatusCode);
|
||||
|
||||
var payload = await response.Content
|
||||
.ReadFromJsonAsync(KlipyJsonContext.Default.KlipySearchResponse, cancellationToken).ConfigureAwait(false);
|
||||
if (payload is null || !payload.Result) throw new HttpRequestException("Klipy did not return a usable result.");
|
||||
|
||||
var rawItems = payload.Data?.Items ?? [];
|
||||
var hasMore = payload.Data?.HasNext ?? rawItems.Length >= perPage;
|
||||
return new KlipySearchResult(ConvertResults(rawItems), hasMore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the selected animated GIF file into memory for clipboard use.
|
||||
/// </summary>
|
||||
/// <param name="gifUrl">Absolute URL of the GIF file to download.</param>
|
||||
/// <param name="cancellationToken">Token used to cancel the download.</param>
|
||||
/// <returns>Raw GIF file bytes.</returns>
|
||||
public static async Task<byte[]> DownloadGifBytesAsync(Uri gifUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await HttpClient.GetAsync(gifUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"Download failed ({(int)response.StatusCode}).", null, response.StatusCode);
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the Klipy search endpoint URL with only page, per_page, and q query parameters.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">Klipy application key used in the API route.</param>
|
||||
/// <param name="query">Search text to pass as the q query parameter.</param>
|
||||
/// <param name="page">One-based Klipy page number to request.</param>
|
||||
/// <param name="perPage">Number of items to request from Klipy for this page.</param>
|
||||
/// <returns>Fully escaped Klipy search URI.</returns>
|
||||
private static Uri BuildSearchUri(string apiKey, string query, int page, int perPage)
|
||||
{
|
||||
var endpoint = $"{BaseUrl}/api/v1/{Uri.EscapeDataString(apiKey)}/gifs/search";
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
throw new InvalidOperationException("The Klipy API key is missing. Set it in Olive settings.");
|
||||
}
|
||||
["page"] = page.ToString(CultureInfo.InvariantCulture),
|
||||
["per_page"] = perPage.ToString(CultureInfo.InvariantCulture),
|
||||
["q"] = query
|
||||
};
|
||||
|
||||
var requestUri = BuildSearchUri(apiKey, query, page, perPage);
|
||||
var queryString = string.Join("&", parameters.Select(pair => string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")));
|
||||
|
||||
using var response = await HttpClient.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return new KlipySearchResult([], HasMore: false);
|
||||
}
|
||||
return new Uri($"{endpoint}?{queryString}");
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Klipy returned {(int)response.StatusCode} {response.ReasonPhrase}.", null, response.StatusCode);
|
||||
}
|
||||
/// <summary>
|
||||
/// Converts raw Klipy response items into Olive GIF models and skips entries without usable GIF URLs.
|
||||
/// </summary>
|
||||
/// <param name="items">Raw item array from the Klipy search response.</param>
|
||||
/// <returns>GIF models that have an ID, thumbnail URL, and full GIF URL.</returns>
|
||||
private static List<KlipyGif> ConvertResults(KlipyItem[]? items)
|
||||
{
|
||||
if (items is null || items.Length == 0) return [];
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync(KlipyJsonContext.Default.KlipySearchResponse, cancellationToken).ConfigureAwait(false);
|
||||
if (payload is null || !payload.Result)
|
||||
{
|
||||
throw new HttpRequestException("Klipy did not return a usable result.");
|
||||
}
|
||||
var gifs = new List<KlipyGif>(items.Length);
|
||||
foreach (var item in items)
|
||||
{
|
||||
var id = !string.IsNullOrWhiteSpace(item.Slug) ? item.Slug : item.Id?.ToString(CultureInfo.InvariantCulture);
|
||||
var thumbnailUrl = item.File?.Sm?.Gif?.Url ?? item.File?.Md?.Gif?.Url ?? item.File?.Hd?.Gif?.Url;
|
||||
var fullUrl = item.File?.Hd?.Gif?.Url ?? item.File?.Md?.Gif?.Url ?? item.File?.Sm?.Gif?.Url;
|
||||
|
||||
var rawItems = payload.Data?.Items ?? [];
|
||||
var hasMore = payload.Data?.HasNext ?? rawItems.Length >= perPage;
|
||||
return new KlipySearchResult(ConvertResults(rawItems), hasMore);
|
||||
if (string.IsNullOrWhiteSpace(id)
|
||||
|| !Uri.TryCreate(thumbnailUrl, UriKind.Absolute, out var thumbnailGifUrl)
|
||||
|| !Uri.TryCreate(fullUrl, UriKind.Absolute, out var gifUrl))
|
||||
continue;
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(item.Title)
|
||||
? "GIF Klipy"
|
||||
: WebUtility.HtmlDecode(item.Title).Trim();
|
||||
|
||||
gifs.Add(new KlipyGif(id, title, thumbnailGifUrl, gifUrl));
|
||||
}
|
||||
|
||||
public static async Task DownloadGifAsync(Uri gifUrl, string destinationPath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await HttpClient.GetAsync(gifUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Download failed ({(int)response.StatusCode}).", null, response.StatusCode);
|
||||
}
|
||||
|
||||
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var output = File.Create(destinationPath);
|
||||
await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static Uri BuildSearchUri(string apiKey, string query, int page, int perPage)
|
||||
{
|
||||
var endpoint = $"{BaseUrl}/api/v1/{Uri.EscapeDataString(apiKey)}/gifs/search";
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["page"] = page.ToString(CultureInfo.InvariantCulture),
|
||||
["per_page"] = perPage.ToString(CultureInfo.InvariantCulture),
|
||||
["q"] = query,
|
||||
};
|
||||
|
||||
var queryString = string.Join("&", parameters.Select(pair => string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")));
|
||||
|
||||
return new Uri($"{endpoint}?{queryString}");
|
||||
}
|
||||
|
||||
private static List<KlipyGif> ConvertResults(KlipyItem[]? items)
|
||||
{
|
||||
if (items is null || items.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var gifs = new List<KlipyGif>(items.Length);
|
||||
foreach (var item in items)
|
||||
{
|
||||
var id = !string.IsNullOrWhiteSpace(item.Slug) ? item.Slug : item.Id?.ToString(CultureInfo.InvariantCulture);
|
||||
var thumbnailUrl = item.File?.Sm?.Gif?.Url ?? item.File?.Md?.Gif?.Url ?? item.File?.Hd?.Gif?.Url;
|
||||
var fullUrl = item.File?.Hd?.Gif?.Url ?? item.File?.Md?.Gif?.Url ?? item.File?.Sm?.Gif?.Url;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id)
|
||||
|| !Uri.TryCreate(thumbnailUrl, UriKind.Absolute, out var thumbnailGifUrl)
|
||||
|| !Uri.TryCreate(fullUrl, UriKind.Absolute, out var gifUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(item.Title)
|
||||
? "GIF Klipy"
|
||||
: WebUtility.HtmlDecode(item.Title).Trim();
|
||||
|
||||
gifs.Add(new KlipyGif(id, title, thumbnailGifUrl, gifUrl));
|
||||
}
|
||||
|
||||
return gifs;
|
||||
}
|
||||
return gifs;
|
||||
}
|
||||
}
|
||||
|
||||
+20
-19
@@ -3,39 +3,40 @@ using System.Text.Json.Serialization;
|
||||
namespace Olive.Klipy;
|
||||
|
||||
internal sealed record KlipyGif(
|
||||
string Id,
|
||||
string Title,
|
||||
Uri ThumbnailGifUrl,
|
||||
Uri GifUrl);
|
||||
string Id,
|
||||
string Title,
|
||||
Uri ThumbnailGifUrl,
|
||||
Uri GifUrl);
|
||||
|
||||
internal sealed record KlipySearchResult(
|
||||
IReadOnlyList<KlipyGif> Gifs,
|
||||
bool HasMore);
|
||||
IReadOnlyList<KlipyGif> Gifs,
|
||||
bool HasMore);
|
||||
|
||||
internal sealed record KlipySearchResponse(
|
||||
[property: JsonPropertyName("result")] bool Result,
|
||||
[property: JsonPropertyName("data")] KlipySearchData? Data);
|
||||
[property: JsonPropertyName("result")] bool Result,
|
||||
[property: JsonPropertyName("data")] KlipySearchData? Data);
|
||||
|
||||
internal sealed record KlipySearchData(
|
||||
[property: JsonPropertyName("data")] KlipyItem[]? Items,
|
||||
[property: JsonPropertyName("has_next")] bool? HasNext);
|
||||
[property: JsonPropertyName("data")] KlipyItem[]? Items,
|
||||
[property: JsonPropertyName("has_next")]
|
||||
bool? HasNext);
|
||||
|
||||
internal sealed record KlipyItem(
|
||||
[property: JsonPropertyName("id")] long? Id,
|
||||
[property: JsonPropertyName("slug")] string? Slug,
|
||||
[property: JsonPropertyName("title")] string? Title,
|
||||
[property: JsonPropertyName("file")] KlipyFile? File);
|
||||
[property: JsonPropertyName("id")] long? Id,
|
||||
[property: JsonPropertyName("slug")] string? Slug,
|
||||
[property: JsonPropertyName("title")] string? Title,
|
||||
[property: JsonPropertyName("file")] KlipyFile? File);
|
||||
|
||||
internal sealed record KlipyFile(
|
||||
[property: JsonPropertyName("hd")] KlipyFileSize? Hd,
|
||||
[property: JsonPropertyName("md")] KlipyFileSize? Md,
|
||||
[property: JsonPropertyName("sm")] KlipyFileSize? Sm);
|
||||
[property: JsonPropertyName("hd")] KlipyFileSize? Hd,
|
||||
[property: JsonPropertyName("md")] KlipyFileSize? Md,
|
||||
[property: JsonPropertyName("sm")] KlipyFileSize? Sm);
|
||||
|
||||
internal sealed record KlipyFileSize(
|
||||
[property: JsonPropertyName("gif")] KlipyMediaFormat? Gif);
|
||||
[property: JsonPropertyName("gif")] KlipyMediaFormat? Gif);
|
||||
|
||||
internal sealed record KlipyMediaFormat(
|
||||
[property: JsonPropertyName("url")] string? Url);
|
||||
[property: JsonPropertyName("url")] string? Url);
|
||||
|
||||
[JsonSerializable(typeof(KlipySearchResponse))]
|
||||
internal sealed partial class KlipyJsonContext : JsonSerializerContext;
|
||||
|
||||
+11
-11
@@ -18,16 +18,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Assets\SplashScreen.scale-200.png" />
|
||||
<Content Include="Assets\AppLogo150.scale-200.png" />
|
||||
<Content Include="Assets\AppLogo44.scale-200.png" />
|
||||
<Content Include="Assets\AppLogo44.targetsize-24_altform-unplated.png" />
|
||||
<Content Include="Assets\StoreLogo.png" />
|
||||
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
|
||||
<Content Include="Assets\SplashScreen.scale-200.png"/>
|
||||
<Content Include="Assets\AppLogo150.scale-200.png"/>
|
||||
<Content Include="Assets\AppLogo44.scale-200.png"/>
|
||||
<Content Include="Assets\AppLogo44.targetsize-24_altform-unplated.png"/>
|
||||
<Content Include="Assets\StoreLogo.png"/>
|
||||
<Content Include="Assets\Wide310x150Logo.scale-200.png"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Manifest Include="$(ApplicationManifest)" />
|
||||
<Manifest Include="$(ApplicationManifest)"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
@@ -36,12 +36,12 @@
|
||||
package has not yet been restored.
|
||||
-->
|
||||
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||
<ProjectCapability Include="Msix" />
|
||||
<ProjectCapability Include="Msix"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CommandPalette.Extensions" />
|
||||
<PackageReference Include="Microsoft.Windows.CsWinRT" />
|
||||
<PackageReference Include="Shmuelie.WinRTServer" />
|
||||
<PackageReference Include="Microsoft.CommandPalette.Extensions"/>
|
||||
<PackageReference Include="Microsoft.Windows.CsWinRT"/>
|
||||
<PackageReference Include="Shmuelie.WinRTServer"/>
|
||||
|
||||
<!-- Needed to enable building an MSIX package -->
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.MSIX">
|
||||
|
||||
@@ -7,25 +7,26 @@ namespace Olive;
|
||||
|
||||
public sealed partial class OliveCommandsProvider : CommandProvider
|
||||
{
|
||||
private readonly SettingsManager _settingsManager = new();
|
||||
private readonly SettingsManager _settingsManager = new();
|
||||
|
||||
public OliveCommandsProvider()
|
||||
{
|
||||
DisplayName = "Olive";
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
Settings = _settingsManager.Settings;
|
||||
}
|
||||
public OliveCommandsProvider()
|
||||
{
|
||||
DisplayName = "Olive";
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
Settings = _settingsManager.Settings;
|
||||
}
|
||||
|
||||
public override ICommandItem[] TopLevelCommands()
|
||||
{
|
||||
return [
|
||||
new CommandItem(new GifPickerPage(_settingsManager))
|
||||
{
|
||||
Title = "Olive GIF Picker",
|
||||
Subtitle = "Browse through lots of GIFs and find the one that suits you best!",
|
||||
MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)],
|
||||
Icon = Icon,
|
||||
},
|
||||
];
|
||||
}
|
||||
public override ICommandItem[] TopLevelCommands()
|
||||
{
|
||||
return
|
||||
[
|
||||
new CommandItem(new GifPickerPage(_settingsManager))
|
||||
{
|
||||
Title = "Olive GIF Picker",
|
||||
Subtitle = "Browse through lots of GIFs and find the one that suits you best!",
|
||||
MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)],
|
||||
Icon = Icon
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+18
-15
@@ -6,23 +6,26 @@ namespace Olive;
|
||||
[Guid("C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A")]
|
||||
public sealed partial class OliveExtension : IExtension, IDisposable
|
||||
{
|
||||
private readonly ManualResetEvent _extensionDisposedEvent;
|
||||
private readonly ManualResetEvent _extensionDisposedEvent;
|
||||
|
||||
private readonly OliveCommandsProvider _provider = new();
|
||||
private readonly OliveCommandsProvider _provider = new();
|
||||
|
||||
public OliveExtension(ManualResetEvent extensionDisposedEvent)
|
||||
public OliveExtension(ManualResetEvent extensionDisposedEvent)
|
||||
{
|
||||
_extensionDisposedEvent = extensionDisposedEvent;
|
||||
}
|
||||
|
||||
public object? GetProvider(ProviderType providerType)
|
||||
{
|
||||
return providerType switch
|
||||
{
|
||||
_extensionDisposedEvent = extensionDisposedEvent;
|
||||
}
|
||||
ProviderType.Commands => _provider,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public object? GetProvider(ProviderType providerType)
|
||||
{
|
||||
return providerType switch
|
||||
{
|
||||
ProviderType.Commands => _provider,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose() => _extensionDisposedEvent.Set();
|
||||
public void Dispose()
|
||||
{
|
||||
_extensionDisposedEvent.Set();
|
||||
}
|
||||
}
|
||||
|
||||
+15
-15
@@ -11,7 +11,7 @@
|
||||
<Identity
|
||||
Name="Olive"
|
||||
Publisher="CN=OlivePrivate"
|
||||
Version="0.0.32.0" />
|
||||
Version="0.0.42.0"/>
|
||||
<Properties>
|
||||
<DisplayName>Olive</DisplayName>
|
||||
<PublisherDisplayName>Private</PublisherDisplayName>
|
||||
@@ -19,8 +19,8 @@
|
||||
</Properties>
|
||||
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0" />
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0" />
|
||||
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0"/>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.19041.0"/>
|
||||
</Dependencies>
|
||||
|
||||
<Resources>
|
||||
@@ -29,35 +29,35 @@
|
||||
|
||||
<Applications>
|
||||
<Application Id="App"
|
||||
Executable="$targetnametoken$.exe"
|
||||
EntryPoint="$targetentrypoint$">
|
||||
Executable="$targetnametoken$.exe"
|
||||
EntryPoint="$targetentrypoint$">
|
||||
<uap:VisualElements
|
||||
DisplayName="Olive"
|
||||
Description="Search and copy animated GIFs from Klipy. Powered by EndMove"
|
||||
BackgroundColor="transparent"
|
||||
Square150x150Logo="Assets\AppLogo150.png"
|
||||
Square44x44Logo="Assets\AppLogo44.png">
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png"/>
|
||||
<uap:SplashScreen Image="Assets\SplashScreen.png"/>
|
||||
</uap:VisualElements>
|
||||
<Extensions>
|
||||
<com:Extension Category="windows.comServer">
|
||||
<com:ComServer>
|
||||
<com:ExeServer Executable="Olive.exe" Arguments="-RegisterProcessAsComServer" DisplayName="Olive">
|
||||
<com:Class Id="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A" DisplayName="Olive" />
|
||||
<com:Class Id="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A" DisplayName="Olive"/>
|
||||
</com:ExeServer>
|
||||
</com:ComServer>
|
||||
</com:Extension>
|
||||
<uap3:Extension Category="windows.appExtension">
|
||||
<uap3:AppExtension Name="com.microsoft.commandpalette"
|
||||
Id="Olive"
|
||||
PublicFolder="Public"
|
||||
DisplayName="Olive"
|
||||
Description="Search and copy animated GIFs from Klipy. Powered by EndMove">
|
||||
Id="Olive"
|
||||
PublicFolder="Public"
|
||||
DisplayName="Olive"
|
||||
Description="Search and copy animated GIFs from Klipy. Powered by EndMove">
|
||||
<uap3:Properties>
|
||||
<CmdPalProvider>
|
||||
<Activation>
|
||||
<CreateInstance ClassId="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A" />
|
||||
<CreateInstance ClassId="C7B0BF27-81E6-4A25-B6F7-F11AB1F61C0A"/>
|
||||
</Activation>
|
||||
<SupportedInterfaces>
|
||||
<Commands/>
|
||||
@@ -71,7 +71,7 @@
|
||||
</Applications>
|
||||
|
||||
<Capabilities>
|
||||
<Capability Name="internetClient" />
|
||||
<rescap:Capability Name="runFullTrust" />
|
||||
<Capability Name="internetClient"/>
|
||||
<rescap:Capability Name="runFullTrust"/>
|
||||
</Capabilities>
|
||||
</Package>
|
||||
|
||||
@@ -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
@@ -1,555 +1,278 @@
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Olive.Commands;
|
||||
using Olive.Helpers;
|
||||
using Olive.Klipy;
|
||||
using Olive.Services;
|
||||
using Windows.Foundation;
|
||||
using Windows.System;
|
||||
|
||||
namespace Olive.Pages;
|
||||
|
||||
internal sealed partial class GifPickerPage : IDynamicListPage
|
||||
{
|
||||
private const int PageSize = 30;
|
||||
private const int InitialBatchPages = 1;
|
||||
private const int LoadMoreBatchPages = 1;
|
||||
private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500);
|
||||
private const int KlipyMaxPageSize = 50;
|
||||
private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly Lock _itemsLock = new();
|
||||
private readonly GifCache _cache = new();
|
||||
private readonly SettingsManager _settingsManager;
|
||||
private readonly List<IListItem> _items = [];
|
||||
private readonly IIconInfo _icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
private readonly IGridProperties _gridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false };
|
||||
private CancellationTokenSource? _searchCancellation;
|
||||
private string _searchText = string.Empty;
|
||||
private string _activeSearch = string.Empty;
|
||||
private string _title = "Olive";
|
||||
private int _searchVersion;
|
||||
private int _loadRequestId;
|
||||
private int _nextPage = 1;
|
||||
private int _loadedItemCount;
|
||||
private bool _hasMoreItems;
|
||||
private bool _isLoading;
|
||||
private bool _isLoadingMore;
|
||||
private ICommandItem _emptyContent;
|
||||
private readonly Lock _stateLock = new();
|
||||
private readonly SettingsManager _settingsManager;
|
||||
private readonly GifListItemFactory _itemFactory;
|
||||
private readonly GifPickerPageState _state;
|
||||
private CancellationTokenSource? _searchCancellation;
|
||||
private string _searchText = string.Empty;
|
||||
private string _activeSearch = string.Empty;
|
||||
private int _searchVersion;
|
||||
private ICommandItem _emptyContent;
|
||||
|
||||
public GifPickerPage(SettingsManager settingsManager)
|
||||
public GifPickerPage(SettingsManager settingsManager)
|
||||
{
|
||||
_settingsManager = settingsManager;
|
||||
_itemFactory = new GifListItemFactory(Icon, _settingsManager);
|
||||
_state = new GifPickerPageState();
|
||||
_emptyContent = _itemFactory.InitialContent();
|
||||
}
|
||||
|
||||
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
|
||||
|
||||
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
|
||||
|
||||
public IIconInfo Icon { get; } = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
|
||||
public string Id => string.Empty;
|
||||
|
||||
public string Name => "Olive GIF Picker";
|
||||
|
||||
public string Title { get; private set; } = "Olive";
|
||||
|
||||
public OptionalColor AccentColor => default;
|
||||
|
||||
public bool IsLoading
|
||||
{
|
||||
get;
|
||||
private set
|
||||
{
|
||||
_settingsManager = settingsManager;
|
||||
_emptyContent = InitialContent();
|
||||
if (field == value) return;
|
||||
|
||||
field = value;
|
||||
RaisePropChanged(nameof(IsLoading));
|
||||
}
|
||||
}
|
||||
|
||||
public string PlaceholderText => "Search for GIFs...";
|
||||
|
||||
public bool ShowDetails => false;
|
||||
|
||||
public bool HasMoreItems => false;
|
||||
|
||||
public IFilters? Filters => null;
|
||||
|
||||
public IGridProperties GridProperties { get; } = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false };
|
||||
|
||||
public ICommandItem EmptyContent
|
||||
{
|
||||
get => _emptyContent;
|
||||
private set
|
||||
{
|
||||
_emptyContent = value;
|
||||
RaisePropChanged(nameof(EmptyContent));
|
||||
}
|
||||
}
|
||||
|
||||
public string? SearchText
|
||||
{
|
||||
get => _searchText;
|
||||
set
|
||||
{
|
||||
value ??= string.Empty;
|
||||
if (StringComparer.Ordinal.Equals(_searchText, value)) return;
|
||||
|
||||
_searchText = value;
|
||||
UpdateSearchText(value);
|
||||
}
|
||||
}
|
||||
|
||||
public IListItem[] GetItems()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return _state.Snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadMore()
|
||||
{
|
||||
}
|
||||
|
||||
private void UpdateSearchText(string newSearch)
|
||||
{
|
||||
CancelSearch();
|
||||
|
||||
var trimmedSearch = newSearch.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedSearch))
|
||||
{
|
||||
ResetSearchState();
|
||||
EmptyContent = _settingsManager.HasKlipyApiKey
|
||||
? _itemFactory.InitialContent()
|
||||
: _itemFactory.MissingApiKeyContent();
|
||||
return;
|
||||
}
|
||||
|
||||
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
|
||||
var version = Interlocked.Increment(ref _searchVersion);
|
||||
var cancellation = new CancellationTokenSource();
|
||||
_searchCancellation = cancellation;
|
||||
|
||||
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
|
||||
_ = SearchAfterDebounceAsync(trimmedSearch, version, cancellation.Token);
|
||||
}
|
||||
|
||||
public IIconInfo Icon => _icon;
|
||||
private void ResetSearchState()
|
||||
{
|
||||
Interlocked.Increment(ref _searchVersion);
|
||||
IsLoading = false;
|
||||
_activeSearch = string.Empty;
|
||||
ReplaceState([]);
|
||||
}
|
||||
|
||||
public string Id => string.Empty;
|
||||
private void CancelSearch()
|
||||
{
|
||||
var cancellation = _searchCancellation;
|
||||
_searchCancellation = null;
|
||||
if (cancellation is null) return;
|
||||
|
||||
public string Name => "Olive GIF Picker";
|
||||
|
||||
public string Title => _title;
|
||||
|
||||
public OptionalColor AccentColor => default;
|
||||
|
||||
public bool IsLoading
|
||||
try
|
||||
{
|
||||
cancellation.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
get => _isLoading;
|
||||
private set
|
||||
{
|
||||
if (_isLoading == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading = value;
|
||||
RaisePropChanged(nameof(IsLoading));
|
||||
}
|
||||
}
|
||||
|
||||
public string PlaceholderText => "Search for GIFs...";
|
||||
cancellation.Dispose();
|
||||
}
|
||||
|
||||
public bool ShowDetails => false;
|
||||
|
||||
public bool HasMoreItems
|
||||
private async Task SearchAfterDebounceAsync(string search, int version, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
get => _hasMoreItems;
|
||||
private set
|
||||
{
|
||||
if (_hasMoreItems == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
await Task.Delay(SearchDebounceDelay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_hasMoreItems = value;
|
||||
RaisePropChanged(nameof(HasMoreItems));
|
||||
}
|
||||
}
|
||||
var apiKey = _settingsManager.KlipyApiKey;
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
if (IsStale(version, cancellationToken)) return;
|
||||
|
||||
public IFilters? Filters => null;
|
||||
|
||||
public IGridProperties GridProperties => _gridProperties;
|
||||
|
||||
public ICommandItem EmptyContent
|
||||
{
|
||||
get => _emptyContent;
|
||||
private set
|
||||
{
|
||||
_emptyContent = value;
|
||||
RaisePropChanged(nameof(EmptyContent));
|
||||
}
|
||||
}
|
||||
|
||||
public string? SearchText
|
||||
{
|
||||
get => _searchText;
|
||||
set
|
||||
{
|
||||
value ??= string.Empty;
|
||||
if (StringComparer.Ordinal.Equals(_searchText, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_searchText = value;
|
||||
UpdateSearchText(value);
|
||||
}
|
||||
}
|
||||
|
||||
public IListItem[] GetItems()
|
||||
{
|
||||
lock (_itemsLock)
|
||||
{
|
||||
return BuildDisplayItemsLocked();
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadMore()
|
||||
{
|
||||
if (_isLoadingMore || !HasMoreItems || string.IsNullOrWhiteSpace(_activeSearch))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var apiKey = _settingsManager.KlipyApiKey;
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
HasMoreItems = false;
|
||||
EmptyContent = MissingApiKeyContent();
|
||||
RaiseItemsChanged(DisplayItemCount());
|
||||
return;
|
||||
}
|
||||
|
||||
var version = _searchVersion;
|
||||
_ = LoadPageBatchAsync(apiKey, _activeSearch, version, LoadMoreBatchPages, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void UpdateSearchText(string newSearch)
|
||||
{
|
||||
CancelSearch();
|
||||
|
||||
var trimmedSearch = newSearch.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedSearch))
|
||||
{
|
||||
Interlocked.Increment(ref _searchVersion);
|
||||
Interlocked.Increment(ref _loadRequestId);
|
||||
IsLoading = false;
|
||||
_isLoadingMore = false;
|
||||
HasMoreItems = false;
|
||||
_activeSearch = string.Empty;
|
||||
_nextPage = 1;
|
||||
ReplaceItems([]);
|
||||
UpdateTitle();
|
||||
EmptyContent = _settingsManager.HasKlipyApiKey ? InitialContent() : MissingApiKeyContent();
|
||||
return;
|
||||
}
|
||||
|
||||
var version = Interlocked.Increment(ref _searchVersion);
|
||||
Interlocked.Increment(ref _loadRequestId);
|
||||
var cancellation = new CancellationTokenSource();
|
||||
_searchCancellation = cancellation;
|
||||
|
||||
_ = SearchAfterDebounceAsync(trimmedSearch, version, cancellation.Token);
|
||||
}
|
||||
|
||||
private void CancelSearch()
|
||||
{
|
||||
var cancellation = _searchCancellation;
|
||||
_searchCancellation = null;
|
||||
if (cancellation is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cancellation.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
|
||||
cancellation.Dispose();
|
||||
}
|
||||
|
||||
private async Task SearchAfterDebounceAsync(string search, int version, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(SearchDebounceDelay, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var apiKey = _settingsManager.KlipyApiKey;
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
HasMoreItems = false;
|
||||
EmptyContent = MissingApiKeyContent();
|
||||
ReplaceItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_activeSearch = search;
|
||||
_nextPage = 1;
|
||||
_isLoadingMore = false;
|
||||
HasMoreItems = false;
|
||||
IsLoading = true;
|
||||
EmptyContent = LoadingContent(search);
|
||||
UpdateTitle();
|
||||
|
||||
await LoadPageBatchAsync(apiKey, search, version, InitialBatchPages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (version == _searchVersion)
|
||||
{
|
||||
ShowSearchError(ex, resetItems: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPageBatchAsync(string apiKey, string search, int version, int pageCount, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_isLoadingMore)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var loadRequestId = Interlocked.Increment(ref _loadRequestId);
|
||||
var startPage = _nextPage;
|
||||
var resetItems = startPage == 1;
|
||||
|
||||
try
|
||||
{
|
||||
_isLoadingMore = true;
|
||||
IsLoading = true;
|
||||
|
||||
var gifs = new List<KlipyGif>(PageSize * pageCount);
|
||||
var nextPage = startPage;
|
||||
var hasMore = false;
|
||||
|
||||
for (var i = 0; i < pageCount && version == _searchVersion && !cancellationToken.IsCancellationRequested; i++)
|
||||
{
|
||||
var page = startPage + i;
|
||||
var result = await KlipyClient.SearchAsync(apiKey, search, page, PageSize, cancellationToken).ConfigureAwait(false);
|
||||
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
gifs.AddRange(result.Gifs);
|
||||
nextPage = page + 1;
|
||||
hasMore = result.HasMore;
|
||||
if (!hasMore)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_nextPage = nextPage;
|
||||
HasMoreItems = hasMore;
|
||||
|
||||
if (resetItems && gifs.Count == 0)
|
||||
{
|
||||
EmptyContent = new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "No GIF found",
|
||||
Subtitle = $"Try another search for \"{search}\".",
|
||||
Icon = Icon,
|
||||
};
|
||||
ReplaceItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
var newItems = gifs.Select(CreateItem).ToArray();
|
||||
if (resetItems)
|
||||
{
|
||||
ReplaceItems(newItems);
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendItems(newItems);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException or TaskCanceledException)
|
||||
{
|
||||
if (version == _searchVersion)
|
||||
{
|
||||
ShowSearchError(ex, resetItems);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (version == _searchVersion)
|
||||
{
|
||||
ShowSearchError(ex, resetItems);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (loadRequestId == _loadRequestId)
|
||||
{
|
||||
_isLoadingMore = false;
|
||||
}
|
||||
|
||||
if (version == _searchVersion && loadRequestId == _loadRequestId)
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowSearchError(Exception ex, bool resetItems)
|
||||
{
|
||||
IsLoading = false;
|
||||
HasMoreItems = false;
|
||||
EmptyContent = new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Search failed",
|
||||
Subtitle = ex.Message,
|
||||
Icon = Icon,
|
||||
};
|
||||
EmptyContent = _itemFactory.MissingApiKeyContent();
|
||||
ReplaceState([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resetItems)
|
||||
{
|
||||
ReplaceItems([]);
|
||||
}
|
||||
else
|
||||
{
|
||||
RaiseItemsChanged(DisplayItemCount());
|
||||
}
|
||||
if (IsStale(version, cancellationToken)) return;
|
||||
|
||||
_activeSearch = search;
|
||||
IsLoading = true;
|
||||
EmptyContent = _itemFactory.LoadingContent(search);
|
||||
UpdateTitle();
|
||||
|
||||
var gifs = await SearchResultsAsync(apiKey, search, _settingsManager.ResultCount, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (IsStale(version, cancellationToken)) return;
|
||||
|
||||
if (gifs.Count == 0)
|
||||
{
|
||||
EmptyContent = _itemFactory.NoResultsContent(search);
|
||||
ReplaceState([]);
|
||||
return;
|
||||
}
|
||||
|
||||
ReplaceState(gifs.Select(GifListItemFactory.CreateGifItem).ToArray());
|
||||
}
|
||||
|
||||
private ListItem CreateItem(KlipyGif gif)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new ListItem(new CopyGifCommand(gif, _cache))
|
||||
{
|
||||
Title = string.Empty,
|
||||
Subtitle = string.Empty,
|
||||
Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()),
|
||||
MoreCommands = CreateLoadMoreContextItems(),
|
||||
};
|
||||
}
|
||||
|
||||
private IContextItem[] CreateLoadMoreContextItems()
|
||||
catch (Exception ex)
|
||||
{
|
||||
return [
|
||||
CreateLoadMoreContextItem("Load 30 more GIFs", VirtualKey.L),
|
||||
];
|
||||
if (version == _searchVersion) ShowSearchError(ex);
|
||||
}
|
||||
|
||||
private CommandContextItem CreateLoadMoreContextItem(string title, VirtualKey key)
|
||||
finally
|
||||
{
|
||||
return new CommandContextItem(new LoadMoreGifsCommand(LoadMore))
|
||||
{
|
||||
Title = title,
|
||||
Subtitle = "Append the next 30 results without moving the current selection",
|
||||
Icon = Icon,
|
||||
RequestedShortcut = KeyChordHelpers.FromModifiers(true, false, false, false, key, 0),
|
||||
};
|
||||
if (version == _searchVersion) IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private CommandItem InitialContent()
|
||||
private static async Task<List<KlipyGif>> SearchResultsAsync(
|
||||
string apiKey,
|
||||
string search,
|
||||
int resultCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var gifs = new List<KlipyGif>(resultCount);
|
||||
var page = 1;
|
||||
|
||||
while (gifs.Count < resultCount)
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = _settingsManager.HasKlipyApiKey ? "Search GIFs" : "Klipy API key missing",
|
||||
Subtitle = _settingsManager.HasKlipyApiKey
|
||||
? "Type something like thanks, excited, cat, or confused."
|
||||
: "Open Olive settings and set the Klipy API key.",
|
||||
Icon = Icon,
|
||||
};
|
||||
var perPage = Math.Min(KlipyMaxPageSize, resultCount - gifs.Count);
|
||||
var result = await KlipyClient.SearchAsync(apiKey, search, page, perPage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
gifs.AddRange(result.Gifs.Take(resultCount - gifs.Count));
|
||||
if (!result.HasMore || result.Gifs.Count == 0) break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
private CommandItem LoadingContent(string search)
|
||||
return gifs;
|
||||
}
|
||||
|
||||
private void ShowSearchError(Exception ex)
|
||||
{
|
||||
IsLoading = false;
|
||||
EmptyContent = _itemFactory.SearchErrorContent(ex);
|
||||
ReplaceState([]);
|
||||
}
|
||||
|
||||
private void ReplaceState(IListItem[] items)
|
||||
{
|
||||
int count;
|
||||
lock (_stateLock)
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Loading GIFs...",
|
||||
Subtitle = $"Searching Klipy for \"{search}\"",
|
||||
Icon = Icon,
|
||||
};
|
||||
_state.ReplaceItems(items);
|
||||
count = _state.DisplayItemCount;
|
||||
}
|
||||
|
||||
private CommandItem MissingApiKeyContent()
|
||||
RaiseItemsChanged(count);
|
||||
UpdateTitle();
|
||||
}
|
||||
|
||||
private int DisplayItemCount()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Klipy API key missing",
|
||||
Subtitle = "Open Olive settings and paste your Klipy API key.",
|
||||
Icon = Icon,
|
||||
};
|
||||
return _state.DisplayItemCount;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReplaceItems(IListItem[] items)
|
||||
{
|
||||
int count;
|
||||
lock (_itemsLock)
|
||||
{
|
||||
_items.Clear();
|
||||
_items.AddRange(items);
|
||||
_loadedItemCount = items.Length;
|
||||
AddPlaceholderItemsLocked();
|
||||
count = DisplayItemCountLocked();
|
||||
}
|
||||
private bool IsStale(int version, CancellationToken cancellationToken)
|
||||
{
|
||||
return version != _searchVersion || cancellationToken.IsCancellationRequested;
|
||||
}
|
||||
|
||||
RaiseItemsChanged(count);
|
||||
UpdateTitle();
|
||||
}
|
||||
private void UpdateTitle()
|
||||
{
|
||||
var loadedCount = DisplayItemCount();
|
||||
var title = string.IsNullOrWhiteSpace(_activeSearch) || loadedCount == 0
|
||||
? "Olive"
|
||||
: $"Olive - {loadedCount} GIFs loaded";
|
||||
|
||||
private void AppendItems(IListItem[] items)
|
||||
{
|
||||
int count;
|
||||
lock (_itemsLock)
|
||||
{
|
||||
RemovePlaceholderItemsLocked();
|
||||
_items.AddRange(items);
|
||||
_loadedItemCount += items.Length;
|
||||
AddPlaceholderItemsLocked();
|
||||
count = DisplayItemCountLocked();
|
||||
}
|
||||
if (StringComparer.Ordinal.Equals(Title, title)) return;
|
||||
|
||||
RaiseItemsChanged(count);
|
||||
UpdateTitle();
|
||||
}
|
||||
Title = title;
|
||||
RaisePropChanged(nameof(Title));
|
||||
}
|
||||
|
||||
private IListItem[] BuildDisplayItemsLocked()
|
||||
{
|
||||
return [.. _items];
|
||||
}
|
||||
|
||||
private void AddPlaceholderItemsLocked()
|
||||
{
|
||||
if (!HasMoreItems)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < PageSize * LoadMoreBatchPages; i++)
|
||||
{
|
||||
_items.Add(CreateVirtualLoadingItem());
|
||||
}
|
||||
}
|
||||
|
||||
private void RemovePlaceholderItemsLocked()
|
||||
{
|
||||
if (_items.Count > _loadedItemCount)
|
||||
{
|
||||
_items.RemoveRange(_loadedItemCount, _items.Count - _loadedItemCount);
|
||||
}
|
||||
}
|
||||
|
||||
private ListItem CreateVirtualLoadingItem()
|
||||
{
|
||||
return new ListItem(new LoadMoreGifsCommand(LoadMore))
|
||||
{
|
||||
Title = "Loading more GIFs...",
|
||||
Subtitle = "Scroll here or press Ctrl+L to load the next 30",
|
||||
Icon = Icon,
|
||||
MoreCommands = CreateLoadMoreContextItems(),
|
||||
};
|
||||
}
|
||||
|
||||
private int DisplayItemCount()
|
||||
{
|
||||
lock (_itemsLock)
|
||||
{
|
||||
return DisplayItemCountLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private int DisplayItemCountLocked()
|
||||
{
|
||||
return _items.Count;
|
||||
}
|
||||
|
||||
private void RaiseItemsChanged(int count)
|
||||
{
|
||||
ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(count));
|
||||
}
|
||||
|
||||
private void RaisePropChanged(string propertyName)
|
||||
{
|
||||
PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
private void UpdateTitle()
|
||||
{
|
||||
var loadedCount = LoadedItemCount();
|
||||
var title = string.IsNullOrWhiteSpace(_activeSearch) || loadedCount == 0
|
||||
? "Olive"
|
||||
: HasMoreItems
|
||||
? $"Olive - {loadedCount} GIFs loaded"
|
||||
: $"Olive - {loadedCount} GIFs loaded (all)";
|
||||
|
||||
if (StringComparer.Ordinal.Equals(_title, title))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_title = title;
|
||||
RaisePropChanged(nameof(Title));
|
||||
}
|
||||
|
||||
private int LoadedItemCount()
|
||||
{
|
||||
lock (_itemsLock)
|
||||
{
|
||||
return _loadedItemCount;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed partial class LoadMoreGifsCommand : InvokableCommand
|
||||
{
|
||||
private readonly Action _loadMore;
|
||||
|
||||
public LoadMoreGifsCommand(Action loadMore)
|
||||
{
|
||||
_loadMore = loadMore;
|
||||
Name = "Load 30 more GIFs";
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
}
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
_loadMore();
|
||||
return CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
private void RaiseItemsChanged(int count)
|
||||
{
|
||||
ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(count));
|
||||
}
|
||||
|
||||
private void RaisePropChanged(string propertyName)
|
||||
{
|
||||
PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -5,35 +5,35 @@ namespace Olive;
|
||||
|
||||
public class Program
|
||||
{
|
||||
[MTAThread]
|
||||
public static void Main(string[] args)
|
||||
[MTAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer")
|
||||
{
|
||||
if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer")
|
||||
{
|
||||
Shmuelie.WinRTServer.ComServer server = new();
|
||||
Shmuelie.WinRTServer.ComServer server = new();
|
||||
|
||||
ManualResetEvent extensionDisposedEvent = new(false);
|
||||
ManualResetEvent extensionDisposedEvent = new(false);
|
||||
|
||||
// We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.
|
||||
// This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object.
|
||||
// If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.
|
||||
OliveExtension extensionInstance = new(extensionDisposedEvent);
|
||||
server.RegisterClass<OliveExtension, IExtension>(() => extensionInstance);
|
||||
server.Start();
|
||||
// We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called.
|
||||
// This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object.
|
||||
// If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate.
|
||||
OliveExtension extensionInstance = new(extensionDisposedEvent);
|
||||
server.RegisterClass<OliveExtension, IExtension>(() => extensionInstance);
|
||||
server.Start();
|
||||
|
||||
// This will make the main thread wait until the event is signalled by the extension class.
|
||||
// Since we have single instance of the extension object, we exit as soon as it is disposed.
|
||||
extensionDisposedEvent.WaitOne();
|
||||
server.Stop();
|
||||
server.UnsafeDispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Windows.Forms.MessageBox.Show(
|
||||
"Olive is a PowerToys Command Palette extension.\n\nOpen PowerToys Command Palette, then launch 'Olive GIF Picker' to search and copy GIFs.",
|
||||
"Olive",
|
||||
System.Windows.Forms.MessageBoxButtons.OK,
|
||||
System.Windows.Forms.MessageBoxIcon.Information);
|
||||
}
|
||||
// This will make the main thread wait until the event is signalled by the extension class.
|
||||
// Since we have single instance of the extension object, we exit as soon as it is disposed.
|
||||
extensionDisposedEvent.WaitOne();
|
||||
server.Stop();
|
||||
server.UnsafeDispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Windows.Forms.MessageBox.Show(
|
||||
"Olive is a PowerToys Command Palette extension.\n\nOpen PowerToys Command Palette, then launch 'Olive GIF Picker' to search and copy GIFs.",
|
||||
"Olive",
|
||||
System.Windows.Forms.MessageBoxButtons.OK,
|
||||
System.Windows.Forms.MessageBoxIcon.Information);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,97 +11,93 @@ namespace Olive.Services;
|
||||
|
||||
internal sealed class ClipboardService
|
||||
{
|
||||
public static async Task CopyGifFileAsync(string gifPath, Uri gifUrl, string title, CancellationToken cancellationToken)
|
||||
public static async Task CopyGifBytesAsync(byte[] gifBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gifBytes);
|
||||
if (gifBytes.Length == 0) throw new InvalidOperationException("GIF data is empty.");
|
||||
|
||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var thread = new Thread(() => CopyOnStaThread(gifBytes, completion))
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(gifPath);
|
||||
ArgumentNullException.ThrowIfNull(gifUrl);
|
||||
Name = "Olive clipboard STA"
|
||||
};
|
||||
|
||||
var fullPath = Path.GetFullPath(gifPath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException("GIF file not found.", fullPath);
|
||||
}
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
|
||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var thread = new Thread(() => CopyOnStaThread(fullPath, completion));
|
||||
cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
|
||||
await completion.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
thread.Name = "Olive clipboard STA";
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
|
||||
cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
|
||||
await completion.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void CopyOnStaThread(string fullPath, TaskCompletionSource completion)
|
||||
private static void CopyOnStaThread(byte[] gifBytes, TaskCompletionSource completion)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clipboardHtml = BuildClipboardHtml(gifBytes);
|
||||
|
||||
const int retryCount = 5;
|
||||
for (var attempt = 1; attempt <= retryCount; attempt++)
|
||||
try
|
||||
{
|
||||
var gifBytes = File.ReadAllBytes(fullPath);
|
||||
var clipboardHtml = BuildClipboardHtml(gifBytes);
|
||||
|
||||
const int retryCount = 5;
|
||||
for (var attempt = 1; attempt <= retryCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new DataObject();
|
||||
data.SetData("image/gif", autoConvert: false, data: GifStream(gifBytes));
|
||||
data.SetData("GIF", autoConvert: false, data: GifStream(gifBytes));
|
||||
data.SetData(DataFormats.Html, autoConvert: false, data: clipboardHtml);
|
||||
data.SetData("text/html", autoConvert: false, data: clipboardHtml);
|
||||
TryAddBitmapPreview(data, fullPath);
|
||||
FormsClipboard.SetDataObject(data, copy: true, retryTimes: 10, retryDelay: 100);
|
||||
completion.TrySetResult();
|
||||
return;
|
||||
}
|
||||
catch (ExternalException) when (attempt < retryCount)
|
||||
{
|
||||
Thread.Sleep(120);
|
||||
}
|
||||
}
|
||||
|
||||
completion.TrySetException(new InvalidOperationException("The clipboard is temporarily unavailable."));
|
||||
var data = new DataObject();
|
||||
data.SetData("image/gif", false, GifStream(gifBytes));
|
||||
data.SetData("GIF", false, GifStream(gifBytes));
|
||||
data.SetData(DataFormats.Html, false, clipboardHtml);
|
||||
data.SetData("text/html", false, clipboardHtml);
|
||||
TryAddBitmapPreview(data, gifBytes);
|
||||
FormsClipboard.SetDataObject(data, true, 10, 100);
|
||||
completion.TrySetResult();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (ExternalException) when (attempt < retryCount)
|
||||
{
|
||||
completion.TrySetException(ex);
|
||||
Thread.Sleep(120);
|
||||
}
|
||||
}
|
||||
|
||||
private static MemoryStream GifStream(byte[] gifBytes)
|
||||
completion.TrySetException(new InvalidOperationException("The clipboard is temporarily unavailable."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MemoryStream(gifBytes, writable: false);
|
||||
completion.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryAddBitmapPreview(DataObject data, string fullPath)
|
||||
private static MemoryStream GifStream(byte[] gifBytes)
|
||||
{
|
||||
return new MemoryStream(gifBytes, false);
|
||||
}
|
||||
|
||||
private static void TryAddBitmapPreview(DataObject data, byte[] gifBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
using var image = Image.FromFile(fullPath);
|
||||
data.SetData(DataFormats.Bitmap, autoConvert: true, data: new Bitmap(image));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ExternalException or OutOfMemoryException)
|
||||
{
|
||||
}
|
||||
using var stream = new MemoryStream(gifBytes, false);
|
||||
using var image = Image.FromStream(stream);
|
||||
data.SetData(DataFormats.Bitmap, true, new Bitmap(image));
|
||||
}
|
||||
|
||||
private static string BuildClipboardHtml(byte[] gifBytes)
|
||||
catch (Exception ex) when (ex is ArgumentException or ExternalException or OutOfMemoryException)
|
||||
{
|
||||
var dataUri = "data:image/gif;base64," + Convert.ToBase64String(gifBytes);
|
||||
var fragment = $"<img src=\"{dataUri}\" alt=\"GIF\">";
|
||||
const string markerPrefix = "Version:1.0\r\nStartHTML:{0:0000000000}\r\nEndHTML:{1:0000000000}\r\nStartFragment:{2:0000000000}\r\nEndFragment:{3:0000000000}\r\n";
|
||||
var prefix = string.Format(CultureInfo.InvariantCulture, markerPrefix, 0, 0, 0, 0);
|
||||
const string beforeFragment = "<!DOCTYPE html><html><body><!--StartFragment-->";
|
||||
const string afterFragment = "<!--EndFragment--></body></html>";
|
||||
var html = beforeFragment + fragment + afterFragment;
|
||||
|
||||
var startHtml = Encoding.UTF8.GetByteCount(prefix);
|
||||
var startFragment = startHtml + Encoding.UTF8.GetByteCount(beforeFragment);
|
||||
var endFragment = startFragment + Encoding.UTF8.GetByteCount(fragment);
|
||||
var endHtml = startHtml + Encoding.UTF8.GetByteCount(html);
|
||||
|
||||
var header = string.Format(CultureInfo.InvariantCulture, markerPrefix, startHtml, endHtml, startFragment, endFragment);
|
||||
return header + html;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildClipboardHtml(byte[] gifBytes)
|
||||
{
|
||||
var dataUri = "data:image/gif;base64," + Convert.ToBase64String(gifBytes);
|
||||
var fragment = $"<img src=\"{dataUri}\" alt=\"GIF\">";
|
||||
const string markerPrefix =
|
||||
"Version:1.0\r\nStartHTML:{0:0000000000}\r\nEndHTML:{1:0000000000}\r\nStartFragment:{2:0000000000}\r\nEndFragment:{3:0000000000}\r\n";
|
||||
var prefix = string.Format(CultureInfo.InvariantCulture, markerPrefix, 0, 0, 0, 0);
|
||||
const string beforeFragment = "<!DOCTYPE html><html><body><!--StartFragment-->";
|
||||
const string afterFragment = "<!--EndFragment--></body></html>";
|
||||
var html = beforeFragment + fragment + afterFragment;
|
||||
|
||||
var startHtml = Encoding.UTF8.GetByteCount(prefix);
|
||||
var startFragment = startHtml + Encoding.UTF8.GetByteCount(beforeFragment);
|
||||
var endFragment = startFragment + Encoding.UTF8.GetByteCount(fragment);
|
||||
var endHtml = startHtml + Encoding.UTF8.GetByteCount(html);
|
||||
|
||||
var header = string.Format(CultureInfo.InvariantCulture, markerPrefix, startHtml, endHtml, startFragment,
|
||||
endFragment);
|
||||
return header + html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -7,7 +7,7 @@
|
||||
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
|
||||
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
|
||||
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
|
||||
@@ -1,19 +1,120 @@
|
||||
# Olive
|
||||
|
||||
Olive is a private Microsoft PowerToys Command Palette extension. It searches animated GIFs with Klipy and allow you to quickly the one that suits you best!
|
||||
Olive
|
||||
is
|
||||
a
|
||||
private
|
||||
Microsoft
|
||||
PowerToys
|
||||
Command
|
||||
Palette
|
||||
extension.
|
||||
It
|
||||
searches
|
||||
animated
|
||||
GIFs
|
||||
with
|
||||
Klipy
|
||||
and
|
||||
allow
|
||||
you
|
||||
to
|
||||
quickly
|
||||
the
|
||||
one
|
||||
that
|
||||
suits
|
||||
you
|
||||
best!
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10/11 with PowerToys and Command Palette enabled.
|
||||
- .NET SDK 10.
|
||||
- Windows SDK `10.0.22621.0` or a compatible newer SDK.
|
||||
- A Klipy API key.
|
||||
-
|
||||
|
||||
Windows
|
||||
10/11
|
||||
with
|
||||
PowerToys
|
||||
and
|
||||
Command
|
||||
Palette
|
||||
enabled.
|
||||
|
||||
-
|
||||
|
||||
.NET
|
||||
SDK
|
||||
|
||||
10.
|
||||
|
||||
-
|
||||
|
||||
Windows
|
||||
SDK
|
||||
`10.0.22621.0`
|
||||
or
|
||||
a
|
||||
compatible
|
||||
newer
|
||||
SDK.
|
||||
|
||||
-
|
||||
|
||||
A
|
||||
Klipy
|
||||
API
|
||||
key.
|
||||
|
||||
## Klipy API Key
|
||||
|
||||
Olive stores the Klipy key locally in its Command Palette settings. Do not put the key in source code, Git, environment variables, or the package.
|
||||
Olive
|
||||
stores
|
||||
the
|
||||
Klipy
|
||||
key
|
||||
locally
|
||||
in
|
||||
its
|
||||
Command
|
||||
Palette
|
||||
settings.
|
||||
Do
|
||||
not
|
||||
put
|
||||
the
|
||||
key
|
||||
in
|
||||
source
|
||||
code,
|
||||
Git,
|
||||
environment
|
||||
variables,
|
||||
or
|
||||
the
|
||||
package.
|
||||
|
||||
To configure it, open Command Palette, open the context commands for `Olive GIF Picker`, open Olive settings, paste the key into `Klipy API key`, then save.
|
||||
To
|
||||
configure
|
||||
it,
|
||||
open
|
||||
Command
|
||||
Palette,
|
||||
open
|
||||
the
|
||||
context
|
||||
commands
|
||||
for
|
||||
`Olive GIF Picker`,
|
||||
open
|
||||
Olive
|
||||
settings,
|
||||
paste
|
||||
the
|
||||
key
|
||||
into
|
||||
`Klipy API key`,
|
||||
then
|
||||
save.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -27,71 +128,251 @@ dotnet build .\Olive.sln -c Debug -p:Platform=x64
|
||||
.\scripts\Build-OlivePackage.ps1
|
||||
```
|
||||
|
||||
Or double-click:
|
||||
Or
|
||||
double-click:
|
||||
|
||||
```text
|
||||
scripts\Build-OlivePackage.cmd
|
||||
```
|
||||
|
||||
This creates the shareable package in `dist\OlivePackage`.
|
||||
This
|
||||
creates
|
||||
the
|
||||
shareable
|
||||
package
|
||||
in
|
||||
`dist\OlivePackage`.
|
||||
|
||||
The private signing file is exported to `dist\private\OlivePrivate.pfx`. Do not share it unless the recipient must be able to sign Olive builds.
|
||||
The
|
||||
private
|
||||
signing
|
||||
file
|
||||
is
|
||||
exported
|
||||
to
|
||||
`dist\private\OlivePrivate.pfx`.
|
||||
Do
|
||||
not
|
||||
share
|
||||
it
|
||||
unless
|
||||
the
|
||||
recipient
|
||||
must
|
||||
be
|
||||
able
|
||||
to
|
||||
sign
|
||||
Olive
|
||||
builds.
|
||||
|
||||
## Install
|
||||
|
||||
From `dist\OlivePackage`:
|
||||
From
|
||||
`dist\OlivePackage`:
|
||||
|
||||
```powershell
|
||||
.\Install-Olive.ps1
|
||||
```
|
||||
|
||||
Or double-click:
|
||||
Or
|
||||
double-click:
|
||||
|
||||
```text
|
||||
Install-Olive.cmd
|
||||
```
|
||||
|
||||
The script asks for administrator rights, imports `OlivePrivate.cer` into `Cert:\LocalMachine\Root`, then opens the `.msix` with Windows App Installer.
|
||||
The
|
||||
script
|
||||
asks
|
||||
for
|
||||
administrator
|
||||
rights,
|
||||
imports
|
||||
`OlivePrivate.cer`
|
||||
into
|
||||
`Cert:\LocalMachine\Root`,
|
||||
then
|
||||
opens
|
||||
the
|
||||
`.msix`
|
||||
with
|
||||
Windows
|
||||
App
|
||||
Installer.
|
||||
|
||||
Restart PowerToys after installation.
|
||||
Restart
|
||||
PowerToys
|
||||
after
|
||||
installation.
|
||||
|
||||
## Uninstall
|
||||
|
||||
From `dist\OlivePackage` or `scripts\`:
|
||||
From
|
||||
`dist\OlivePackage`
|
||||
or
|
||||
`scripts\`:
|
||||
|
||||
```powershell
|
||||
.\Uninstall-Olive.ps1
|
||||
```
|
||||
|
||||
Or double-click:
|
||||
Or
|
||||
double-click:
|
||||
|
||||
```text
|
||||
Uninstall-Olive.cmd
|
||||
```
|
||||
|
||||
By default, uninstall removes the Olive package, the Olive certificate from `Cert:\LocalMachine\Root`, local settings, cached GIFs, and MSIX user data.
|
||||
By
|
||||
default,
|
||||
uninstall
|
||||
removes
|
||||
the
|
||||
Olive
|
||||
package,
|
||||
the
|
||||
Olive
|
||||
certificate
|
||||
from
|
||||
`Cert:\LocalMachine\Root`,
|
||||
and
|
||||
Olive
|
||||
user
|
||||
data.
|
||||
|
||||
Use `-KeepCertificate` to keep the certificate.
|
||||
Use
|
||||
`-KeepCertificate`
|
||||
to
|
||||
keep
|
||||
the
|
||||
certificate.
|
||||
|
||||
Use `-KeepUserData` to keep local settings and cached GIFs.
|
||||
Use
|
||||
`-KeepUserData`
|
||||
to
|
||||
keep
|
||||
Olive
|
||||
user
|
||||
data.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open PowerToys Command Palette.
|
||||
2. Launch `Olive GIF Picker`.
|
||||
3. Search for a GIF.
|
||||
4. Select a result with Enter.
|
||||
5. Paste with Ctrl+V in an app that accepts animated GIF files.
|
||||
1.
|
||||
|
||||
Open
|
||||
PowerToys
|
||||
Command
|
||||
Palette.
|
||||
|
||||
2.
|
||||
|
||||
Launch
|
||||
`Olive GIF Picker`.
|
||||
|
||||
3.
|
||||
|
||||
Search
|
||||
for
|
||||
a
|
||||
GIF.
|
||||
|
||||
4.
|
||||
|
||||
Select
|
||||
a
|
||||
result
|
||||
with
|
||||
Enter.
|
||||
|
||||
5.
|
||||
|
||||
Paste
|
||||
with
|
||||
Ctrl+V
|
||||
in
|
||||
an
|
||||
app
|
||||
that
|
||||
accepts
|
||||
animated
|
||||
GIF
|
||||
files.
|
||||
|
||||
## Sharing
|
||||
|
||||
- Share only `dist\OlivePackage`.
|
||||
- Do not share `dist\private\OlivePrivate.pfx`.
|
||||
- Do not include the Klipy API key in the package.
|
||||
- Each user must set the Klipy key in Olive settings.
|
||||
-
|
||||
|
||||
Share
|
||||
only
|
||||
`dist\OlivePackage`.
|
||||
|
||||
-
|
||||
|
||||
Do
|
||||
not
|
||||
share
|
||||
`dist\private\OlivePrivate.pfx`.
|
||||
|
||||
-
|
||||
|
||||
Do
|
||||
not
|
||||
include
|
||||
the
|
||||
Klipy
|
||||
API
|
||||
key
|
||||
in
|
||||
the
|
||||
package.
|
||||
|
||||
-
|
||||
|
||||
Each
|
||||
user
|
||||
must
|
||||
set
|
||||
the
|
||||
Klipy
|
||||
key
|
||||
in
|
||||
Olive
|
||||
settings.
|
||||
|
||||
## Notes
|
||||
|
||||
- Olive copies GIFs as files to preserve animation.
|
||||
- Some apps may reject file pasting or use fallback text only.
|
||||
- Cached GIFs are stored in `%LOCALAPPDATA%\Olive\Cache`.
|
||||
-
|
||||
|
||||
Olive
|
||||
copies
|
||||
GIFs
|
||||
as
|
||||
files
|
||||
to
|
||||
preserve
|
||||
animation.
|
||||
|
||||
-
|
||||
|
||||
Some
|
||||
apps
|
||||
may
|
||||
reject
|
||||
file
|
||||
pasting
|
||||
or
|
||||
use
|
||||
fallback
|
||||
text
|
||||
only.
|
||||
|
||||
-
|
||||
|
||||
GIFs
|
||||
are
|
||||
downloaded
|
||||
in
|
||||
memory
|
||||
when
|
||||
copied.
|
||||
|
||||
+9
-9
@@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
<packageSources>
|
||||
<clear/>
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json"/>
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*"/>
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
|
||||
@@ -24,9 +24,9 @@ function Confirm-Continue {
|
||||
Write-Host "It will keep the trusted certificate because -KeepCertificate was used."
|
||||
}
|
||||
if ($removeUserDataNow) {
|
||||
Write-Host "It will also remove Olive local settings and cached GIFs."
|
||||
Write-Host "It will also remove Olive user data."
|
||||
} else {
|
||||
Write-Host "It will keep Olive local settings and cached GIFs because -KeepUserData was used."
|
||||
Write-Host "It will keep Olive user data because -KeepUserData was used."
|
||||
}
|
||||
$answer = Read-Host "Continue? Type YES"
|
||||
if ($answer -ne "YES") {
|
||||
@@ -87,7 +87,6 @@ if ($packages.Count -eq 0) {
|
||||
if (-not $KeepUserData) {
|
||||
$localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
|
||||
$userDataPaths = @(
|
||||
(Join-Path $localAppData "Olive"),
|
||||
(Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Extensions\Olive"),
|
||||
(Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Settings\Olive"),
|
||||
(Join-Path $localAppData "Microsoft\PowerToys\CommandPalette\Olive")
|
||||
@@ -97,6 +96,12 @@ if (-not $KeepUserData) {
|
||||
$userDataPaths += Join-Path $localAppData "Packages\$packageFamilyName"
|
||||
}
|
||||
|
||||
$packagesRoot = Join-Path $localAppData "Packages"
|
||||
if (Test-Path -LiteralPath $packagesRoot) {
|
||||
$userDataPaths += Get-ChildItem -LiteralPath $packagesRoot -Directory -Filter "Olive_*" |
|
||||
ForEach-Object { $_.FullName }
|
||||
}
|
||||
|
||||
foreach ($path in ($userDataPaths | Sort-Object -Unique)) {
|
||||
Remove-DirectoryIfExists -Path $path
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user