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
|
||||
@@ -8,12 +8,10 @@ namespace Olive.Commands;
|
||||
internal sealed partial class CopyGifCommand : InvokableCommand
|
||||
{
|
||||
private readonly KlipyGif _gif;
|
||||
private readonly GifCache _cache;
|
||||
|
||||
public CopyGifCommand(KlipyGif gif, GifCache cache)
|
||||
public CopyGifCommand(KlipyGif gif)
|
||||
{
|
||||
_gif = gif;
|
||||
_cache = cache;
|
||||
Name = "Copy GIF";
|
||||
Icon = new IconInfo("\uE8C8");
|
||||
}
|
||||
@@ -21,7 +19,7 @@ internal sealed partial class CopyGifCommand : InvokableCommand
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
_ = CopyAsync();
|
||||
return CommandResult.KeepOpen();
|
||||
return CommandResult.Hide();
|
||||
}
|
||||
|
||||
private async Task CopyAsync()
|
||||
@@ -30,15 +28,16 @@ internal sealed partial class CopyGifCommand : InvokableCommand
|
||||
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);
|
||||
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)
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or InvalidOperationException
|
||||
or UnauthorizedAccessException)
|
||||
{
|
||||
ShowStatus("Could not copy this GIF.", MessageState.Error, 3500);
|
||||
}
|
||||
|
||||
@@ -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,20 +2,47 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Olive.Helpers;
|
||||
|
||||
internal sealed partial class SettingsManager : JsonSettingsManager
|
||||
internal sealed class SettingsManager : JsonSettingsManager
|
||||
{
|
||||
private const string Namespace = "Olive";
|
||||
|
||||
public SettingsManager()
|
||||
{
|
||||
FilePath = SettingsJsonPath();
|
||||
Settings.Add(_klipyApiKey);
|
||||
Settings.Add(_resultCount);
|
||||
|
||||
// Load settings from file upon initialization
|
||||
LoadSettings();
|
||||
|
||||
Settings.SettingsChanged += (_, _) => SaveSettings();
|
||||
}
|
||||
|
||||
public string SettingsPath => FilePath;
|
||||
|
||||
private readonly TextSetting _klipyApiKey = new(
|
||||
Namespaced(nameof(KlipyApiKey)),
|
||||
"Klipy API key",
|
||||
"Private key used to call the Klipy API. It stays stored locally in Olive settings.",
|
||||
string.Empty)
|
||||
{
|
||||
Placeholder = "Paste your Klipy API key here",
|
||||
Placeholder = "Required",
|
||||
IsRequired = true,
|
||||
ErrorMessage = "Klipy API key is required."
|
||||
};
|
||||
|
||||
private static string Namespaced(string propertyName) => $"{Namespace}.{propertyName}";
|
||||
private readonly IntegerSetting _resultCount = new(
|
||||
Namespaced(nameof(ResultCount)),
|
||||
"Result count",
|
||||
"Number of GIF results to load for each search.",
|
||||
50,
|
||||
20,
|
||||
100)
|
||||
{
|
||||
Placeholder = "Min 20, max 100",
|
||||
IsRequired = true,
|
||||
ErrorMessage = "Enter a number between 20 and 100."
|
||||
};
|
||||
|
||||
public string KlipyApiKey
|
||||
{
|
||||
@@ -28,9 +55,16 @@ internal sealed partial class SettingsManager : JsonSettingsManager
|
||||
|
||||
public bool HasKlipyApiKey => !string.IsNullOrWhiteSpace(KlipyApiKey);
|
||||
|
||||
public string SettingsPath => FilePath;
|
||||
public int ResultCount
|
||||
{
|
||||
get
|
||||
{
|
||||
LoadSettings();
|
||||
return Math.Clamp(_resultCount.Value, 30, 100);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string SettingsJsonPath()
|
||||
private static string SettingsJsonPath()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Olive");
|
||||
Directory.CreateDirectory(directory);
|
||||
@@ -38,15 +72,8 @@ internal sealed partial class SettingsManager : JsonSettingsManager
|
||||
return Path.Combine(directory, "settings.json");
|
||||
}
|
||||
|
||||
public SettingsManager()
|
||||
private static string Namespaced(string propertyName)
|
||||
{
|
||||
FilePath = SettingsJsonPath();
|
||||
|
||||
Settings.Add(_klipyApiKey);
|
||||
|
||||
// Load settings from file upon initialization
|
||||
LoadSettings();
|
||||
|
||||
Settings.SettingsChanged += (_, _) => SaveSettings();
|
||||
return $"{Namespace}.{propertyName}";
|
||||
}
|
||||
}
|
||||
|
||||
+45
-31
@@ -9,50 +9,64 @@ internal sealed class KlipyClient
|
||||
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([], HasMore: false);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 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);
|
||||
}
|
||||
|
||||
public static async Task DownloadGifAsync(Uri gifUrl, string destinationPath, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Downloads the selected animated GIF file into memory for clipboard use.
|
||||
/// </summary>
|
||||
/// <param name="gifUrl">Absolute URL of the GIF file to download.</param>
|
||||
/// <param name="cancellationToken">Token used to cancel the download.</param>
|
||||
/// <returns>Raw GIF file bytes.</returns>
|
||||
public static async Task<byte[]> DownloadGifBytesAsync(Uri gifUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await HttpClient.GetAsync(gifUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await HttpClient.GetAsync(gifUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Download failed ({(int)response.StatusCode}).", null, response.StatusCode);
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var output = File.Create(destinationPath);
|
||||
await input.CopyToAsync(output, 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";
|
||||
@@ -60,7 +74,7 @@ internal sealed class KlipyClient
|
||||
{
|
||||
["page"] = page.ToString(CultureInfo.InvariantCulture),
|
||||
["per_page"] = perPage.ToString(CultureInfo.InvariantCulture),
|
||||
["q"] = query,
|
||||
["q"] = query
|
||||
};
|
||||
|
||||
var queryString = string.Join("&", parameters.Select(pair => string.Create(
|
||||
@@ -70,12 +84,14 @@ internal sealed class KlipyClient
|
||||
return new Uri($"{endpoint}?{queryString}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts raw Klipy response items into Olive GIF models and skips entries without usable GIF URLs.
|
||||
/// </summary>
|
||||
/// <param name="items">Raw item array from the Klipy search response.</param>
|
||||
/// <returns>GIF models that have an ID, thumbnail URL, and full GIF URL.</returns>
|
||||
private static List<KlipyGif> ConvertResults(KlipyItem[]? items)
|
||||
{
|
||||
if (items is null || items.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
if (items is null || items.Length == 0) return [];
|
||||
|
||||
var gifs = new List<KlipyGif>(items.Length);
|
||||
foreach (var item in items)
|
||||
@@ -87,9 +103,7 @@ internal sealed class KlipyClient
|
||||
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"
|
||||
|
||||
@@ -18,7 +18,8 @@ internal sealed record KlipySearchResponse(
|
||||
|
||||
internal sealed record KlipySearchData(
|
||||
[property: JsonPropertyName("data")] KlipyItem[]? Items,
|
||||
[property: JsonPropertyName("has_next")] bool? HasNext);
|
||||
[property: JsonPropertyName("has_next")]
|
||||
bool? HasNext);
|
||||
|
||||
internal sealed record KlipyItem(
|
||||
[property: JsonPropertyName("id")] long? Id,
|
||||
|
||||
@@ -18,14 +18,15 @@ public sealed partial class OliveCommandsProvider : CommandProvider
|
||||
|
||||
public override ICommandItem[] TopLevelCommands()
|
||||
{
|
||||
return [
|
||||
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,
|
||||
},
|
||||
Icon = Icon
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,8 @@ public sealed partial class OliveExtension : IExtension, IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose() => _extensionDisposedEvent.Set();
|
||||
public void Dispose()
|
||||
{
|
||||
_extensionDisposedEvent.Set();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Olive.Commands;
|
||||
using Olive.Helpers;
|
||||
using Olive.Klipy;
|
||||
|
||||
namespace Olive.Pages;
|
||||
|
||||
internal sealed class GifListItemFactory
|
||||
{
|
||||
private readonly IIconInfo _icon;
|
||||
private readonly SettingsManager _settingsManager;
|
||||
|
||||
public GifListItemFactory(IIconInfo icon, SettingsManager settingsManager)
|
||||
{
|
||||
_icon = icon;
|
||||
_settingsManager = settingsManager;
|
||||
}
|
||||
|
||||
public static ListItem CreateGifItem(KlipyGif gif)
|
||||
{
|
||||
return new ListItem(new CopyGifCommand(gif))
|
||||
{
|
||||
Title = string.Empty,
|
||||
Subtitle = string.Empty,
|
||||
Icon = new IconInfo(gif.ThumbnailGifUrl.ToString())
|
||||
};
|
||||
}
|
||||
|
||||
public CommandItem InitialContent()
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = _settingsManager.HasKlipyApiKey ? "Search GIFs" : "Klipy API key missing",
|
||||
Subtitle = _settingsManager.HasKlipyApiKey
|
||||
? "Type something like thanks, excited, cat, or confused."
|
||||
: "Open Olive settings and set the Klipy API key.",
|
||||
Icon = _icon
|
||||
};
|
||||
}
|
||||
|
||||
public CommandItem LoadingContent(string search)
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Loading GIFs...",
|
||||
Subtitle = $"Searching Klipy for \"{search}\"",
|
||||
Icon = _icon
|
||||
};
|
||||
}
|
||||
|
||||
public CommandItem MissingApiKeyContent()
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Klipy API key missing",
|
||||
Subtitle = "Open Olive settings and paste your Klipy API key.",
|
||||
Icon = _icon
|
||||
};
|
||||
}
|
||||
|
||||
public CommandItem NoResultsContent(string search)
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "No GIF found",
|
||||
Subtitle = $"Try another search for \"{search}\".",
|
||||
Icon = _icon
|
||||
};
|
||||
}
|
||||
|
||||
public CommandItem SearchErrorContent(Exception ex)
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Search failed",
|
||||
Subtitle = ex.Message,
|
||||
Icon = _icon
|
||||
};
|
||||
}
|
||||
}
|
||||
+98
-375
@@ -1,71 +1,56 @@
|
||||
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 const int KlipyMaxPageSize = 50;
|
||||
private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly Lock _itemsLock = new();
|
||||
private readonly GifCache _cache = new();
|
||||
private readonly Lock _stateLock = 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 readonly GifListItemFactory _itemFactory;
|
||||
private readonly GifPickerPageState _state;
|
||||
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;
|
||||
|
||||
public GifPickerPage(SettingsManager settingsManager)
|
||||
{
|
||||
_settingsManager = settingsManager;
|
||||
_emptyContent = InitialContent();
|
||||
_itemFactory = new GifListItemFactory(Icon, _settingsManager);
|
||||
_state = new GifPickerPageState();
|
||||
_emptyContent = _itemFactory.InitialContent();
|
||||
}
|
||||
|
||||
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
|
||||
|
||||
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
|
||||
|
||||
public IIconInfo Icon => _icon;
|
||||
public IIconInfo Icon { get; } = IconHelpers.FromRelativePath("Assets\\StoreLogo.png");
|
||||
|
||||
public string Id => string.Empty;
|
||||
|
||||
public string Name => "Olive GIF Picker";
|
||||
|
||||
public string Title => _title;
|
||||
public string Title { get; private set; } = "Olive";
|
||||
|
||||
public OptionalColor AccentColor => default;
|
||||
|
||||
public bool IsLoading
|
||||
{
|
||||
get => _isLoading;
|
||||
get;
|
||||
private set
|
||||
{
|
||||
if (_isLoading == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (field == value) return;
|
||||
|
||||
_isLoading = value;
|
||||
field = value;
|
||||
RaisePropChanged(nameof(IsLoading));
|
||||
}
|
||||
}
|
||||
@@ -74,24 +59,11 @@ internal sealed partial class GifPickerPage : IDynamicListPage
|
||||
|
||||
public bool ShowDetails => false;
|
||||
|
||||
public bool HasMoreItems
|
||||
{
|
||||
get => _hasMoreItems;
|
||||
private set
|
||||
{
|
||||
if (_hasMoreItems == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hasMoreItems = value;
|
||||
RaisePropChanged(nameof(HasMoreItems));
|
||||
}
|
||||
}
|
||||
public bool HasMoreItems => false;
|
||||
|
||||
public IFilters? Filters => null;
|
||||
|
||||
public IGridProperties GridProperties => _gridProperties;
|
||||
public IGridProperties GridProperties { get; } = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false };
|
||||
|
||||
public ICommandItem EmptyContent
|
||||
{
|
||||
@@ -109,10 +81,7 @@ internal sealed partial class GifPickerPage : IDynamicListPage
|
||||
set
|
||||
{
|
||||
value ??= string.Empty;
|
||||
if (StringComparer.Ordinal.Equals(_searchText, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (StringComparer.Ordinal.Equals(_searchText, value)) return;
|
||||
|
||||
_searchText = value;
|
||||
UpdateSearchText(value);
|
||||
@@ -121,30 +90,14 @@ internal sealed partial class GifPickerPage : IDynamicListPage
|
||||
|
||||
public IListItem[] GetItems()
|
||||
{
|
||||
lock (_itemsLock)
|
||||
lock (_stateLock)
|
||||
{
|
||||
return BuildDisplayItemsLocked();
|
||||
return _state.Snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -154,35 +107,33 @@ internal sealed partial class GifPickerPage : IDynamicListPage
|
||||
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();
|
||||
ResetSearchState();
|
||||
EmptyContent = _settingsManager.HasKlipyApiKey
|
||||
? _itemFactory.InitialContent()
|
||||
: _itemFactory.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 ResetSearchState()
|
||||
{
|
||||
Interlocked.Increment(ref _searchVersion);
|
||||
IsLoading = false;
|
||||
_activeSearch = string.Empty;
|
||||
ReplaceState([]);
|
||||
}
|
||||
|
||||
private void CancelSearch()
|
||||
{
|
||||
var cancellation = _searchCancellation;
|
||||
_searchCancellation = null;
|
||||
if (cancellation is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (cancellation is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -204,298 +155,115 @@ internal sealed partial class GifPickerPage : IDynamicListPage
|
||||
var apiKey = _settingsManager.KlipyApiKey;
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (IsStale(version, cancellationToken)) return;
|
||||
|
||||
IsLoading = false;
|
||||
HasMoreItems = false;
|
||||
EmptyContent = MissingApiKeyContent();
|
||||
ReplaceItems([]);
|
||||
EmptyContent = _itemFactory.MissingApiKeyContent();
|
||||
ReplaceState([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (version != _searchVersion || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (IsStale(version, cancellationToken)) return;
|
||||
|
||||
_activeSearch = search;
|
||||
_nextPage = 1;
|
||||
_isLoadingMore = false;
|
||||
HasMoreItems = false;
|
||||
IsLoading = true;
|
||||
EmptyContent = LoadingContent(search);
|
||||
EmptyContent = _itemFactory.LoadingContent(search);
|
||||
UpdateTitle();
|
||||
|
||||
await LoadPageBatchAsync(apiKey, search, version, InitialBatchPages, cancellationToken).ConfigureAwait(false);
|
||||
var gifs = await SearchResultsAsync(apiKey, search, _settingsManager.ResultCount, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (IsStale(version, cancellationToken)) return;
|
||||
|
||||
if (gifs.Count == 0)
|
||||
{
|
||||
EmptyContent = _itemFactory.NoResultsContent(search);
|
||||
ReplaceState([]);
|
||||
return;
|
||||
}
|
||||
|
||||
ReplaceState(gifs.Select(GifListItemFactory.CreateGifItem).ToArray());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (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);
|
||||
}
|
||||
if (version == _searchVersion) ShowSearchError(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (loadRequestId == _loadRequestId)
|
||||
{
|
||||
_isLoadingMore = false;
|
||||
if (version == _searchVersion) IsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (version == _searchVersion && loadRequestId == _loadRequestId)
|
||||
private static async Task<List<KlipyGif>> SearchResultsAsync(
|
||||
string apiKey,
|
||||
string search,
|
||||
int resultCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var gifs = new List<KlipyGif>(resultCount);
|
||||
var page = 1;
|
||||
|
||||
while (gifs.Count < resultCount)
|
||||
{
|
||||
var perPage = Math.Min(KlipyMaxPageSize, resultCount - gifs.Count);
|
||||
var result = await KlipyClient.SearchAsync(apiKey, search, page, perPage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
gifs.AddRange(result.Gifs.Take(resultCount - gifs.Count));
|
||||
if (!result.HasMore || result.Gifs.Count == 0) break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return gifs;
|
||||
}
|
||||
|
||||
private void ShowSearchError(Exception ex)
|
||||
{
|
||||
IsLoading = false;
|
||||
}
|
||||
}
|
||||
EmptyContent = _itemFactory.SearchErrorContent(ex);
|
||||
ReplaceState([]);
|
||||
}
|
||||
|
||||
private void ShowSearchError(Exception ex, bool resetItems)
|
||||
{
|
||||
IsLoading = false;
|
||||
HasMoreItems = false;
|
||||
EmptyContent = new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Search failed",
|
||||
Subtitle = ex.Message,
|
||||
Icon = Icon,
|
||||
};
|
||||
|
||||
if (resetItems)
|
||||
{
|
||||
ReplaceItems([]);
|
||||
}
|
||||
else
|
||||
{
|
||||
RaiseItemsChanged(DisplayItemCount());
|
||||
}
|
||||
}
|
||||
|
||||
private ListItem CreateItem(KlipyGif gif)
|
||||
{
|
||||
return new ListItem(new CopyGifCommand(gif, _cache))
|
||||
{
|
||||
Title = string.Empty,
|
||||
Subtitle = string.Empty,
|
||||
Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()),
|
||||
MoreCommands = CreateLoadMoreContextItems(),
|
||||
};
|
||||
}
|
||||
|
||||
private IContextItem[] CreateLoadMoreContextItems()
|
||||
{
|
||||
return [
|
||||
CreateLoadMoreContextItem("Load 30 more GIFs", VirtualKey.L),
|
||||
];
|
||||
}
|
||||
|
||||
private CommandContextItem CreateLoadMoreContextItem(string title, VirtualKey key)
|
||||
{
|
||||
return new CommandContextItem(new LoadMoreGifsCommand(LoadMore))
|
||||
{
|
||||
Title = title,
|
||||
Subtitle = "Append the next 30 results without moving the current selection",
|
||||
Icon = Icon,
|
||||
RequestedShortcut = KeyChordHelpers.FromModifiers(true, false, false, false, key, 0),
|
||||
};
|
||||
}
|
||||
|
||||
private CommandItem InitialContent()
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = _settingsManager.HasKlipyApiKey ? "Search GIFs" : "Klipy API key missing",
|
||||
Subtitle = _settingsManager.HasKlipyApiKey
|
||||
? "Type something like thanks, excited, cat, or confused."
|
||||
: "Open Olive settings and set the Klipy API key.",
|
||||
Icon = Icon,
|
||||
};
|
||||
}
|
||||
|
||||
private CommandItem LoadingContent(string search)
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Loading GIFs...",
|
||||
Subtitle = $"Searching Klipy for \"{search}\"",
|
||||
Icon = Icon,
|
||||
};
|
||||
}
|
||||
|
||||
private CommandItem MissingApiKeyContent()
|
||||
{
|
||||
return new CommandItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Klipy API key missing",
|
||||
Subtitle = "Open Olive settings and paste your Klipy API key.",
|
||||
Icon = Icon,
|
||||
};
|
||||
}
|
||||
|
||||
private void ReplaceItems(IListItem[] items)
|
||||
private void ReplaceState(IListItem[] items)
|
||||
{
|
||||
int count;
|
||||
lock (_itemsLock)
|
||||
lock (_stateLock)
|
||||
{
|
||||
_items.Clear();
|
||||
_items.AddRange(items);
|
||||
_loadedItemCount = items.Length;
|
||||
AddPlaceholderItemsLocked();
|
||||
count = DisplayItemCountLocked();
|
||||
_state.ReplaceItems(items);
|
||||
count = _state.DisplayItemCount;
|
||||
}
|
||||
|
||||
RaiseItemsChanged(count);
|
||||
UpdateTitle();
|
||||
}
|
||||
|
||||
private void AppendItems(IListItem[] items)
|
||||
{
|
||||
int count;
|
||||
lock (_itemsLock)
|
||||
{
|
||||
RemovePlaceholderItemsLocked();
|
||||
_items.AddRange(items);
|
||||
_loadedItemCount += items.Length;
|
||||
AddPlaceholderItemsLocked();
|
||||
count = DisplayItemCountLocked();
|
||||
}
|
||||
|
||||
RaiseItemsChanged(count);
|
||||
UpdateTitle();
|
||||
}
|
||||
|
||||
private IListItem[] BuildDisplayItemsLocked()
|
||||
{
|
||||
return [.. _items];
|
||||
}
|
||||
|
||||
private void AddPlaceholderItemsLocked()
|
||||
{
|
||||
if (!HasMoreItems)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < PageSize * LoadMoreBatchPages; i++)
|
||||
{
|
||||
_items.Add(CreateVirtualLoadingItem());
|
||||
}
|
||||
}
|
||||
|
||||
private void RemovePlaceholderItemsLocked()
|
||||
{
|
||||
if (_items.Count > _loadedItemCount)
|
||||
{
|
||||
_items.RemoveRange(_loadedItemCount, _items.Count - _loadedItemCount);
|
||||
}
|
||||
}
|
||||
|
||||
private ListItem CreateVirtualLoadingItem()
|
||||
{
|
||||
return new ListItem(new LoadMoreGifsCommand(LoadMore))
|
||||
{
|
||||
Title = "Loading more GIFs...",
|
||||
Subtitle = "Scroll here or press Ctrl+L to load the next 30",
|
||||
Icon = Icon,
|
||||
MoreCommands = CreateLoadMoreContextItems(),
|
||||
};
|
||||
}
|
||||
|
||||
private int DisplayItemCount()
|
||||
{
|
||||
lock (_itemsLock)
|
||||
lock (_stateLock)
|
||||
{
|
||||
return DisplayItemCountLocked();
|
||||
return _state.DisplayItemCount;
|
||||
}
|
||||
}
|
||||
|
||||
private int DisplayItemCountLocked()
|
||||
private bool IsStale(int version, CancellationToken cancellationToken)
|
||||
{
|
||||
return _items.Count;
|
||||
return version != _searchVersion || cancellationToken.IsCancellationRequested;
|
||||
}
|
||||
|
||||
private void UpdateTitle()
|
||||
{
|
||||
var loadedCount = DisplayItemCount();
|
||||
var title = string.IsNullOrWhiteSpace(_activeSearch) || loadedCount == 0
|
||||
? "Olive"
|
||||
: $"Olive - {loadedCount} GIFs loaded";
|
||||
|
||||
if (StringComparer.Ordinal.Equals(Title, title)) return;
|
||||
|
||||
Title = title;
|
||||
RaisePropChanged(nameof(Title));
|
||||
}
|
||||
|
||||
private void RaiseItemsChanged(int count)
|
||||
@@ -507,49 +275,4 @@ internal sealed partial class GifPickerPage : IDynamicListPage
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,21 +11,17 @@ 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)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(gifPath);
|
||||
ArgumentNullException.ThrowIfNull(gifUrl);
|
||||
|
||||
var fullPath = Path.GetFullPath(gifPath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException("GIF file not found.", fullPath);
|
||||
}
|
||||
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(fullPath, completion));
|
||||
var thread = new Thread(() => CopyOnStaThread(gifBytes, completion))
|
||||
{
|
||||
Name = "Olive clipboard STA"
|
||||
};
|
||||
|
||||
thread.Name = "Olive clipboard STA";
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
|
||||
@@ -33,25 +29,23 @@ internal sealed class ClipboardService
|
||||
await completion.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void CopyOnStaThread(string fullPath, TaskCompletionSource completion)
|
||||
private static void CopyOnStaThread(byte[] gifBytes, TaskCompletionSource completion)
|
||||
{
|
||||
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);
|
||||
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;
|
||||
}
|
||||
@@ -59,7 +53,6 @@ internal sealed class ClipboardService
|
||||
{
|
||||
Thread.Sleep(120);
|
||||
}
|
||||
}
|
||||
|
||||
completion.TrySetException(new InvalidOperationException("The clipboard is temporarily unavailable."));
|
||||
}
|
||||
@@ -71,15 +64,16 @@ internal sealed class ClipboardService
|
||||
|
||||
private static MemoryStream GifStream(byte[] gifBytes)
|
||||
{
|
||||
return new MemoryStream(gifBytes, writable: false);
|
||||
return new MemoryStream(gifBytes, false);
|
||||
}
|
||||
|
||||
private static void TryAddBitmapPreview(DataObject data, string fullPath)
|
||||
private static void TryAddBitmapPreview(DataObject data, byte[] gifBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var image = Image.FromFile(fullPath);
|
||||
data.SetData(DataFormats.Bitmap, autoConvert: true, data: new Bitmap(image));
|
||||
using var stream = new MemoryStream(gifBytes, false);
|
||||
using var image = Image.FromStream(stream);
|
||||
data.SetData(DataFormats.Bitmap, true, new Bitmap(image));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or ExternalException or OutOfMemoryException)
|
||||
{
|
||||
@@ -90,7 +84,8 @@ internal sealed class ClipboardService
|
||||
{
|
||||
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";
|
||||
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>";
|
||||
@@ -101,7 +96,8 @@ internal sealed class ClipboardService
|
||||
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);
|
||||
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,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.
|
||||
|
||||
@@ -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