Implement media rule editor and enhance gallery functionality
- Introduced a rule editor in the gallery for users to define actions when a media item is found again, allowing for better management of media rules. - Updated `GalleryViewModel` to handle rule actions, including saving, editing, and removing rules, with appropriate UI bindings. - Enhanced UI components in `GalleryView.axaml` to support rule editing, including action selection and source management. - Added localization strings for new rule-related features in both English and Russian. - Improved unit tests to cover new rule management functionalities, ensuring robust behavior during rule creation and editing. These changes significantly enhance the user experience by providing a more interactive and flexible way to manage media items in the gallery.
This commit is contained in:
@@ -76,6 +76,30 @@
|
|||||||
повторный рендер, который тихо разъедется с шаблоном. Ctrl+C без выделения копирует весь журнал.
|
повторный рендер, который тихо разъедется с шаблоном. Ctrl+C без выделения копирует весь журнал.
|
||||||
В Avalonia 12 `SetTextAsync` — расширение из `Avalonia.Input.Platform`, а не член `IClipboard`.
|
В Avalonia 12 `SetTextAsync` — расширение из `Avalonia.Input.Platform`, а не член `IClipboard`.
|
||||||
|
|
||||||
|
## Правила на картинку
|
||||||
|
|
||||||
|
Пользователь открывает картинку в галерее и говорит, что делать, когда она попадётся снова:
|
||||||
|
`MediaRule` (SHA-256 → действие + список источников), таблица `media_rule`, редактор — оверлей
|
||||||
|
`x:Name="RuleEditor"` в `GalleryView`.
|
||||||
|
|
||||||
|
- **Опознание по байтам, и только.** Ни адрес, ни размер не отличают «удалено» и «недоступно в вашей
|
||||||
|
стране» от настоящей картинки — сервис отдаёт и то и другое честным 200. Перцептивных хешей здесь
|
||||||
|
нет намеренно, поэтому пережатая версия того же баннера — другое правило; притворяться иначе значит
|
||||||
|
тихо терять настоящие находки.
|
||||||
|
- **Область — источники.** Одни и те же байты у одного хоста заглушка, у другого обычная картинка.
|
||||||
|
Пусто = все источники. Новое правило открывается отмеченным на источнике этой картинки.
|
||||||
|
- **`Skip` удаляет и уже собранную копию.** Правило ставят, глядя на мусор в галерее; оставить его на
|
||||||
|
диске значит выполнить половину просьбы. Удаление идёт через существующий tombstone — он один умеет
|
||||||
|
снимать копии вместе со ссылками showcase.
|
||||||
|
- **`RetryElsewhere` перезапрашивает тот же адрес через другую страну**, до `MaxCountryAttempts`, и
|
||||||
|
**требует прокси даже у источника, которому разрешено прямое подключение**: смысл в том, чтобы
|
||||||
|
прийти откуда-то ещё, а свой адрес — ровно то место, где баннер уже видели.
|
||||||
|
- **Некуда идти — ничего не сохраняем, адрес остаётся повторяемым** (`GeoBlocked`, нетерминальный).
|
||||||
|
Баннер это не картинка, а записать его как решённый исход значит сжечь id навсегда из-за
|
||||||
|
сегодняшнего состава пула. Прокси без страны в фиде исключить не из чего — это тоже конец попыток.
|
||||||
|
- **Старые tombstone приезжают в ту же карту как безобластной `Skip`**: у фетчера один поиск на
|
||||||
|
скачивание, а правило по тому же хешу перебивает, как более позднее и более конкретное.
|
||||||
|
|
||||||
## Хранилище медиа
|
## Хранилище медиа
|
||||||
|
|
||||||
- **В `blobs/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на
|
- **В `blobs/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
всего набора — то есть из пары тысяч непроверенных адресов, и почти каждый запрос платил полный
|
всего набора — то есть из пары тысяч непроверенных адресов, и почти каждый запрос платил полный
|
||||||
connect-таймаут, чтобы это выяснить. Теперь «живых N» на странице и то, куда реально ходит сбор, —
|
connect-таймаут, чтобы это выяснить. Теперь «живых N» на странице и то, куда реально ходит сбор, —
|
||||||
одно и то же число.
|
одно и то же число.
|
||||||
|
- **`AcquireAsync(excludedCountries)` — для правила «попробовать из другой страны».** Записи, у
|
||||||
|
которых фид не назвал страну, остаются в игре: неизвестная страна — не совпадение, и отбрасывать их
|
||||||
|
значит сужать пул из-за пустого поля.
|
||||||
- **Фолбэк на всё доступное, когда живых нет.** Непроверенная ≠ мёртвая: на холодном пуле строгий
|
- **Фолбэк на всё доступное, когда живых нет.** Непроверенная ≠ мёртвая: на холодном пуле строгий
|
||||||
фильтр не выдал бы ничего и сбор просто встал бы, не попробовав. Ответившая на этом пути прокси
|
фильтр не выдал бы ничего и сбор просто встал бы, не попробовав. Ответившая на этом пути прокси
|
||||||
сама себя переводит в живые.
|
сама себя переводит в живые.
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ public interface IProxyPool
|
|||||||
/// stated stay eligible: an unknown country is not a known match, and refusing them would turn a
|
/// stated stay eligible: an unknown country is not a known match, and refusing them would turn a
|
||||||
/// missing field into a smaller pool.
|
/// missing field into a smaller pool.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
Task<ProxyLease?> AcquireAsync(IReadOnlySet<string>? excludedCountries, CancellationToken cancellationToken = default);
|
Task<ProxyLease?> AcquireAsync(
|
||||||
|
IReadOnlySet<string>? excludedCountries,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
);
|
||||||
|
|
||||||
/// <summary>Probes every entry in parallel and updates their health.</summary>
|
/// <summary>Probes every entry in parallel and updates their health.</summary>
|
||||||
/// <returns>How many answered.</returns>
|
/// <returns>How many answered.</returns>
|
||||||
|
|||||||
@@ -140,8 +140,7 @@ public sealed class MediaFetcher(
|
|||||||
foreach (var address in candidate.Addresses)
|
foreach (var address in candidate.Addresses)
|
||||||
{
|
{
|
||||||
var attempt = address == candidate.Url ? candidate : candidate with { Url = address, Alternatives = [] };
|
var attempt = address == candidate.Url ? candidate : candidate with { Url = address, Alternatives = [] };
|
||||||
var result = await FetchWithRulesAsync(attempt, options, stopwatch, token, userToken)
|
var result = await FetchWithRulesAsync(attempt, options, stopwatch, token, userToken).ConfigureAwait(false);
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (!IsMissing(result.Outcome))
|
if (!IsMissing(result.Outcome))
|
||||||
{
|
{
|
||||||
@@ -224,13 +223,7 @@ public sealed class MediaFetcher(
|
|||||||
|
|
||||||
/// <summary>The verdict when the picture is a known geo-block and there is nowhere else to be.</summary>
|
/// <summary>The verdict when the picture is a known geo-block and there is nowhere else to be.</summary>
|
||||||
private static FetchResult GeoBlocked(Uri url, Stopwatch stopwatch, HashSet<string> tried) =>
|
private static FetchResult GeoBlocked(Uri url, Stopwatch stopwatch, HashSet<string> tried) =>
|
||||||
Failure(
|
Failure(url, SeenOutcome.Failed, "GeoBlocked", tried.Count == 0 ? null : string.Join(", ", tried), stopwatch);
|
||||||
url,
|
|
||||||
SeenOutcome.Failed,
|
|
||||||
"GeoBlocked",
|
|
||||||
tried.Count == 0 ? null : string.Join(", ", tried),
|
|
||||||
stopwatch
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>Whether the address simply held nothing, which is the only reason to try the next one.</summary>
|
/// <summary>Whether the address simply held nothing, which is the only reason to try the next one.</summary>
|
||||||
private static bool IsMissing(SeenOutcome outcome) =>
|
private static bool IsMissing(SeenOutcome outcome) =>
|
||||||
@@ -601,7 +594,15 @@ public sealed class MediaFetcher(
|
|||||||
var outcome =
|
var outcome =
|
||||||
rule == MediaRuleAction.Skip
|
rule == MediaRuleAction.Skip
|
||||||
? Failure(url, SeenOutcome.Placeholder, "Placeholder", null, stopwatch, leased.ProxyKey, status)
|
? Failure(url, SeenOutcome.Placeholder, "Placeholder", null, stopwatch, leased.ProxyKey, status)
|
||||||
: Failure(url, SeenOutcome.Failed, RetryElsewhereCode, null, stopwatch, leased.ProxyKey, status);
|
: Failure(
|
||||||
|
url,
|
||||||
|
SeenOutcome.Failed,
|
||||||
|
RetryElsewhereCode,
|
||||||
|
null,
|
||||||
|
stopwatch,
|
||||||
|
leased.ProxyKey,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
|
||||||
return (outcome with { ProxyCountry = leased.ProxyCountry }, true, null);
|
return (outcome with { ProxyCountry = leased.ProxyCountry }, true, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public static class UiServiceCollectionExtensions
|
|||||||
));
|
));
|
||||||
services.AddSingleton<GalleryViewModel>(static sp => new GalleryViewModel(
|
services.AddSingleton<GalleryViewModel>(static sp => new GalleryViewModel(
|
||||||
sp.GetRequiredService<IMediaStore>(),
|
sp.GetRequiredService<IMediaStore>(),
|
||||||
|
sp.GetRequiredService<IMediaSourceCatalog>(),
|
||||||
sp.GetRequiredService<IThumbnailCache>(),
|
sp.GetRequiredService<IThumbnailCache>(),
|
||||||
sp.GetRequiredService<ILogger<GalleryViewModel>>()
|
sp.GetRequiredService<ILogger<GalleryViewModel>>()
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -814,6 +814,45 @@
|
|||||||
<data name="Gallery.Hash" xml:space="preserve">
|
<data name="Gallery.Hash" xml:space="preserve">
|
||||||
<value>CONTENT HASH</value>
|
<value>CONTENT HASH</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Gallery.Rule.Edit" xml:space="preserve">
|
||||||
|
<value>When found again…</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Title" xml:space="preserve">
|
||||||
|
<value>When this picture turns up again</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Hint" xml:space="preserve">
|
||||||
|
<value>Matched by content, byte for byte: a re-encoded or resized version of the same picture is a different one.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Action" xml:space="preserve">
|
||||||
|
<value>ACTION</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Sources" xml:space="preserve">
|
||||||
|
<value>SOURCES</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.SourcesHint" xml:space="preserve">
|
||||||
|
<value>Nothing ticked means every source. The same bytes can be a placeholder on one host and an ordinary picture on another.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Save" xml:space="preserve">
|
||||||
|
<value>Save</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Cancel" xml:space="preserve">
|
||||||
|
<value>Cancel</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Remove" xml:space="preserve">
|
||||||
|
<value>Remove the rule</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Saved" xml:space="preserve">
|
||||||
|
<value>Rule saved: {0}. Removed {1} copy(ies).</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Removed" xml:space="preserve">
|
||||||
|
<value>Rule removed. What it deleted does not come back.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Enum.MediaRuleAction.Skip" xml:space="preserve">
|
||||||
|
<value>Skip: never keep this picture</value>
|
||||||
|
</data>
|
||||||
|
<data name="Enum.MediaRuleAction.RetryElsewhere" xml:space="preserve">
|
||||||
|
<value>Try again through another country</value>
|
||||||
|
</data>
|
||||||
<data name="Gallery.Close" xml:space="preserve">
|
<data name="Gallery.Close" xml:space="preserve">
|
||||||
<value>Close</value>
|
<value>Close</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -814,6 +814,45 @@
|
|||||||
<data name="Gallery.Hash" xml:space="preserve">
|
<data name="Gallery.Hash" xml:space="preserve">
|
||||||
<value>ХЕШ СОДЕРЖИМОГО</value>
|
<value>ХЕШ СОДЕРЖИМОГО</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Gallery.Rule.Edit" xml:space="preserve">
|
||||||
|
<value>Действие при находке…</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Title" xml:space="preserve">
|
||||||
|
<value>Когда эта картинка попадётся снова</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Hint" xml:space="preserve">
|
||||||
|
<value>Сравнение по содержимому, байт в байт: пережатая или изменённая в размере версия той же картинки — уже другая.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Action" xml:space="preserve">
|
||||||
|
<value>ДЕЙСТВИЕ</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Sources" xml:space="preserve">
|
||||||
|
<value>ИСТОЧНИКИ</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.SourcesHint" xml:space="preserve">
|
||||||
|
<value>Ничего не отмечено — значит для всех источников. Одни и те же байты у одного хоста заглушка, у другого обычная картинка.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Save" xml:space="preserve">
|
||||||
|
<value>Сохранить</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Cancel" xml:space="preserve">
|
||||||
|
<value>Отмена</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Remove" xml:space="preserve">
|
||||||
|
<value>Удалить правило</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Saved" xml:space="preserve">
|
||||||
|
<value>Правило сохранено: {0}. Удалено копий: {1}.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Gallery.Rule.Removed" xml:space="preserve">
|
||||||
|
<value>Правило удалено. То, что оно стёрло, не возвращается.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Enum.MediaRuleAction.Skip" xml:space="preserve">
|
||||||
|
<value>Пропускать: никогда не сохранять</value>
|
||||||
|
</data>
|
||||||
|
<data name="Enum.MediaRuleAction.RetryElsewhere" xml:space="preserve">
|
||||||
|
<value>Пробовать снова через другую страну</value>
|
||||||
|
</data>
|
||||||
<data name="Gallery.Close" xml:space="preserve">
|
<data name="Gallery.Close" xml:space="preserve">
|
||||||
<value>Закрыть</value>
|
<value>Закрыть</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
|
|||||||
|
|
||||||
/// <summary>Creates the page.</summary>
|
/// <summary>Creates the page.</summary>
|
||||||
/// <param name="store">Where the content is.</param>
|
/// <param name="store">Where the content is.</param>
|
||||||
|
/// <param name="catalog">Sources a rule can be scoped to.</param>
|
||||||
/// <param name="thumbnails">Decodes and caches tile bitmaps.</param>
|
/// <param name="thumbnails">Decodes and caches tile bitmaps.</param>
|
||||||
/// <param name="logger">Diagnostics.</param>
|
/// <param name="logger">Diagnostics.</param>
|
||||||
/// <param name="mainThread">Scheduler for UI-affine updates; tests pass an immediate one.</param>
|
/// <param name="mainThread">Scheduler for UI-affine updates; tests pass an immediate one.</param>
|
||||||
@@ -148,7 +149,14 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
|
|||||||
this.WhenAnyValue(x => x.HasRule),
|
this.WhenAnyValue(x => x.HasRule),
|
||||||
_mainThread
|
_mainThread
|
||||||
);
|
);
|
||||||
CancelRuleCommand = ReactiveCommand.Create(() => IsRuleEditorOpen = false, editing, _mainThread);
|
CancelRuleCommand = ReactiveCommand.Create(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
IsRuleEditorOpen = false;
|
||||||
|
},
|
||||||
|
editing,
|
||||||
|
_mainThread
|
||||||
|
);
|
||||||
|
|
||||||
EditRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed);
|
EditRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed);
|
||||||
SaveRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed);
|
SaveRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed);
|
||||||
@@ -218,6 +226,31 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
|
|||||||
/// <summary>Closes the viewer.</summary>
|
/// <summary>Closes the viewer.</summary>
|
||||||
public ReactiveCommand<RxVoid, GalleryItemViewModel?> CloseViewerCommand { get; }
|
public ReactiveCommand<RxVoid, GalleryItemViewModel?> CloseViewerCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Opens the "what to do when this turns up again" panel for the open picture.</summary>
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> EditRuleCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Saves the rule for the open picture.</summary>
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> SaveRuleCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Drops the rule for the open picture.</summary>
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> RemoveRuleCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Closes the panel without saving.</summary>
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> CancelRuleCommand { get; }
|
||||||
|
|
||||||
|
/// <summary>Actions the editor offers.</summary>
|
||||||
|
public IReadOnlyList<LocalizedOption<MediaRuleAction>> RuleActions { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sources the rule may cover, each with its own tick.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Ticked rather than a single choice because the same bytes mean different things per service:
|
||||||
|
/// one host's "removed" placeholder is another host's ordinary picture. Nothing ticked means
|
||||||
|
/// every source, which is what a user who does not care about the distinction will leave it as.
|
||||||
|
/// </remarks>
|
||||||
|
public ObservableCollection<MediaSourceViewModel> RuleSources { get; }
|
||||||
|
|
||||||
/// <summary>Whether the viewer is open.</summary>
|
/// <summary>Whether the viewer is open.</summary>
|
||||||
public bool IsViewerOpen => Selected is not null;
|
public bool IsViewerOpen => Selected is not null;
|
||||||
|
|
||||||
@@ -339,6 +372,114 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
|
|||||||
|
|
||||||
private static bool HasNextPage(int page, int total, bool loading) => !loading && (page + 1) * PageSize < total;
|
private static bool HasNextPage(int page, int total, bool loading) => !loading && (page + 1) * PageSize < total;
|
||||||
|
|
||||||
|
/// <summary>Fills the editor from whatever rule the open picture already has.</summary>
|
||||||
|
private async Task OpenRuleEditorAsync()
|
||||||
|
{
|
||||||
|
if (Selected is not { } item)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules = await _store.LoadRulesAsync().ConfigureAwait(false);
|
||||||
|
var existing = rules.FirstOrDefault(rule =>
|
||||||
|
string.Equals(rule.Sha256, item.Media.Sha256, StringComparison.Ordinal)
|
||||||
|
);
|
||||||
|
|
||||||
|
var scoped = new HashSet<string>(existing?.Sources ?? [], StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
OnUi(() =>
|
||||||
|
{
|
||||||
|
foreach (var source in RuleSources)
|
||||||
|
{
|
||||||
|
source.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
RuleSources.Clear();
|
||||||
|
|
||||||
|
foreach (var source in _catalog.Sources)
|
||||||
|
{
|
||||||
|
RuleSources.Add(new MediaSourceViewModel(source) { IsSelected = scoped.Contains(source.Id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new rule starts on the source this picture came from: it is the one the user is
|
||||||
|
// looking at, and scoping to it is nearly always what they mean.
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
foreach (var source in RuleSources.Where(source => source.Id == item.Media.SourceId))
|
||||||
|
{
|
||||||
|
source.IsSelected = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RuleAction = RuleActions.First(option => option.Value == (existing?.Action ?? MediaRuleAction.Skip));
|
||||||
|
HasRule = existing is not null;
|
||||||
|
IsRuleEditorOpen = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveRuleAsync()
|
||||||
|
{
|
||||||
|
if (Selected is not { } item)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rule = new MediaRule(item.Media.Sha256, RuleAction.Value)
|
||||||
|
{
|
||||||
|
SourceIds = MediaRule.JoinSources(
|
||||||
|
RuleSources.Where(source => source.IsSelected).Select(source => source.Id)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
var removed = await _store.SaveRuleAsync(rule).ConfigureAwait(false);
|
||||||
|
var message = Localizer.Instance.Format("Gallery.Rule.Saved", RuleAction.Label, removed);
|
||||||
|
|
||||||
|
OnUi(() =>
|
||||||
|
{
|
||||||
|
IsRuleEditorOpen = false;
|
||||||
|
HasRule = true;
|
||||||
|
|
||||||
|
// A skip rule deletes the copy that prompted it, so the page behind the viewer is now
|
||||||
|
// showing something that is gone.
|
||||||
|
if (removed > 0)
|
||||||
|
{
|
||||||
|
Selected = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (removed > 0)
|
||||||
|
{
|
||||||
|
await LoadAsync(PageIndex).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the reload, not before: reading a page rewrites the status line, so saying it first
|
||||||
|
// would leave the user with an empty toolbar and no sign anything happened.
|
||||||
|
OnUi(() => StatusMessage = message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveRuleAsync()
|
||||||
|
{
|
||||||
|
if (Selected is not { } item)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _store.RemoveRuleAsync(item.Media.Sha256).ConfigureAwait(false);
|
||||||
|
|
||||||
|
OnUi(() =>
|
||||||
|
{
|
||||||
|
HasRule = false;
|
||||||
|
IsRuleEditorOpen = false;
|
||||||
|
StatusMessage = Localizer.Instance["Gallery.Rule.Removed"];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRuleFailed(Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "The gallery rule could not be applied");
|
||||||
|
OnUi(() => StatusMessage = Localizer.Instance.Format("Gallery.Failed", exception.Message));
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnLanguageChanged()
|
protected override void OnLanguageChanged()
|
||||||
{
|
{
|
||||||
@@ -371,6 +512,11 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
|
|||||||
loading.Dispose();
|
loading.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var source in RuleSources)
|
||||||
|
{
|
||||||
|
source.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
GC.SuppressFinalize(this);
|
GC.SuppressFinalize(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,12 +198,78 @@
|
|||||||
Margin="0,0,32,32"
|
Margin="0,0,32,32"
|
||||||
Spacing="8"
|
Spacing="8"
|
||||||
>
|
>
|
||||||
|
<Button Command="{Binding EditRuleCommand}" ToolTip.Tip="{l:Loc Gallery.Rule.Hint}">
|
||||||
|
<TextBlock Text="{l:Loc Gallery.Rule.Edit}" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button Classes="primary" Command="{Binding CloseViewerCommand}">
|
<Button Classes="primary" Command="{Binding CloseViewerCommand}">
|
||||||
<TextBlock Text="{l:Loc Gallery.Close}" />
|
<TextBlock Text="{l:Loc Gallery.Close}" />
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ===== Rule editor ===== -->
|
||||||
|
<Border
|
||||||
|
x:Name="RuleEditor"
|
||||||
|
Background="{DynamicResource AppSurfaceSunkenBrush}"
|
||||||
|
IsVisible="{Binding IsRuleEditorOpen}"
|
||||||
|
>
|
||||||
|
<Border Classes="card" MaxWidth="520" VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="subtitle" Text="{l:Loc Gallery.Rule.Title}" />
|
||||||
|
<TextBlock Classes="muted" Text="{l:Loc Gallery.Rule.Hint}" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="caption" Text="{l:Loc Gallery.Rule.Action}" />
|
||||||
|
<ComboBox
|
||||||
|
ItemsSource="{Binding RuleActions}"
|
||||||
|
SelectedItem="{Binding RuleAction}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
>
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="l:LocalizedOption">
|
||||||
|
<TextBlock Text="{Binding Label}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="caption" Text="{l:Loc Gallery.Rule.Sources}" />
|
||||||
|
<Border
|
||||||
|
BorderThickness="1"
|
||||||
|
BorderBrush="{DynamicResource AppBorderBrush}"
|
||||||
|
CornerRadius="4"
|
||||||
|
MaxHeight="160"
|
||||||
|
>
|
||||||
|
<ItemsControl x:Name="RuleSourceList" ItemsSource="{Binding RuleSources}" Margin="10">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:MediaSourceViewModel">
|
||||||
|
<CheckBox IsChecked="{Binding IsSelected}" Content="{Binding Name}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Classes="muted caption" Text="{l:Loc Gallery.Rule.SourcesHint}" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||||
|
<Button Classes="destructive" Command="{Binding RemoveRuleCommand}">
|
||||||
|
<TextBlock Text="{l:Loc Gallery.Rule.Remove}" />
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding CancelRuleCommand}">
|
||||||
|
<TextBlock Text="{l:Loc Gallery.Rule.Cancel}" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="primary" Command="{Binding SaveRuleCommand}">
|
||||||
|
<TextBlock Text="{l:Loc Gallery.Rule.Save}" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ===== Pager ===== -->
|
<!-- ===== Pager ===== -->
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using AvParser.Core.Collecting;
|
using AvParser.Core.Collecting;
|
||||||
|
using AvParser.Core.Collecting.Sources;
|
||||||
using AvParser.UI.Tests.Fakes;
|
using AvParser.UI.Tests.Fakes;
|
||||||
using AvParser.UI.ViewModels;
|
using AvParser.UI.ViewModels;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -25,6 +26,49 @@ public class GalleryViewModelTests
|
|||||||
IsAnimated = animated,
|
IsAnimated = animated,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>An in-memory catalog, so a rule can be scoped to a source in tests.</summary>
|
||||||
|
private sealed class FakeUserSourceStore(IEnumerable<PatternSourceConfig> seed) : IUserSourceStore
|
||||||
|
{
|
||||||
|
private readonly List<PatternSourceConfig> _configs = [.. seed];
|
||||||
|
|
||||||
|
public event EventHandler? Changed
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<PatternSourceConfig> List() => [.. _configs];
|
||||||
|
|
||||||
|
public Task<PatternSourceConfig> AddAsync(
|
||||||
|
PatternSourceConfig config,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
) => Task.FromResult(config);
|
||||||
|
|
||||||
|
public Task<bool> UpdateAsync(PatternSourceConfig config, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(false);
|
||||||
|
|
||||||
|
public Task<bool> RemoveAsync(string id, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PatternSourceConfig Config(string id)
|
||||||
|
{
|
||||||
|
PatternSourceConfig.TryCreate(
|
||||||
|
id,
|
||||||
|
"https://imgtest.example/x/",
|
||||||
|
6,
|
||||||
|
8,
|
||||||
|
IdAlphabet.Alphanumeric,
|
||||||
|
null,
|
||||||
|
".jpg",
|
||||||
|
allowDirectConnection: false,
|
||||||
|
out var config,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
|
return config!;
|
||||||
|
}
|
||||||
|
|
||||||
private static (GalleryViewModel Page, FakeMediaStore Store, FakeThumbnailCache Thumbnails) Build(
|
private static (GalleryViewModel Page, FakeMediaStore Store, FakeThumbnailCache Thumbnails) Build(
|
||||||
params StoredMedia[] items
|
params StoredMedia[] items
|
||||||
)
|
)
|
||||||
@@ -36,6 +80,7 @@ public class GalleryViewModelTests
|
|||||||
var thumbnails = new FakeThumbnailCache();
|
var thumbnails = new FakeThumbnailCache();
|
||||||
var page = new GalleryViewModel(
|
var page = new GalleryViewModel(
|
||||||
store,
|
store,
|
||||||
|
new MediaSourceCatalog(new FakeUserSourceStore([Config("url-list"), Config("other")])),
|
||||||
thumbnails,
|
thumbnails,
|
||||||
NullLogger<GalleryViewModel>.Instance,
|
NullLogger<GalleryViewModel>.Instance,
|
||||||
ImmediateSequencer.Instance
|
ImmediateSequencer.Instance
|
||||||
@@ -171,6 +216,90 @@ public class GalleryViewModelTests
|
|||||||
page.IsLoading.ShouldBeFalse();
|
page.IsLoading.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_new_rule_starts_scoped_to_the_source_the_picture_came_from()
|
||||||
|
{
|
||||||
|
var (page, _, _) = Build(Media("https://a.test/1.png"));
|
||||||
|
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||||
|
page.Selected = page.Items[0];
|
||||||
|
|
||||||
|
await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
page.IsRuleEditorOpen.ShouldBeTrue();
|
||||||
|
page.HasRule.ShouldBeFalse();
|
||||||
|
page.RuleSources.Single(source => source.IsSelected).Id.ShouldBe("url-list");
|
||||||
|
page.RuleAction.Value.ShouldBe(MediaRuleAction.Skip);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Saving_a_skip_rule_records_it_and_drops_the_copy_that_prompted_it()
|
||||||
|
{
|
||||||
|
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||||
|
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||||
|
page.Selected = page.Items[0];
|
||||||
|
var hash = page.Selected.Media.Sha256;
|
||||||
|
|
||||||
|
await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||||
|
await page.SaveRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var rule = store.Rules.ShouldHaveSingleItem();
|
||||||
|
rule.Sha256.ShouldBe(hash);
|
||||||
|
rule.Action.ShouldBe(MediaRuleAction.Skip);
|
||||||
|
rule.AppliesTo("url-list").ShouldBeTrue();
|
||||||
|
rule.AppliesTo("other").ShouldBeFalse();
|
||||||
|
|
||||||
|
// The thing the rule was made about is gone, so the viewer cannot keep showing it.
|
||||||
|
page.IsRuleEditorOpen.ShouldBeFalse();
|
||||||
|
page.Selected.ShouldBeNull();
|
||||||
|
page.StatusMessage.ShouldNotBeNull().ShouldContain("Rule saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_rule_with_no_source_ticked_covers_every_source()
|
||||||
|
{
|
||||||
|
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||||
|
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||||
|
page.Selected = page.Items[0];
|
||||||
|
|
||||||
|
await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
foreach (var source in page.RuleSources)
|
||||||
|
{
|
||||||
|
source.IsSelected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.RuleAction = page.RuleActions.Single(option => option.Value == MediaRuleAction.RetryElsewhere);
|
||||||
|
await page.SaveRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var rule = store.Rules.ShouldHaveSingleItem();
|
||||||
|
rule.SourceIds.ShouldBeNull();
|
||||||
|
rule.AppliesTo("anything-at-all").ShouldBeTrue();
|
||||||
|
rule.Action.ShouldBe(MediaRuleAction.RetryElsewhere);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_existing_rule_comes_back_into_the_editor()
|
||||||
|
{
|
||||||
|
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||||
|
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||||
|
page.Selected = page.Items[0];
|
||||||
|
|
||||||
|
store.Rules.Add(
|
||||||
|
new MediaRule(page.Selected.Media.Sha256, MediaRuleAction.RetryElsewhere) { SourceIds = "other" }
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
page.HasRule.ShouldBeTrue();
|
||||||
|
page.RuleAction.Value.ShouldBe(MediaRuleAction.RetryElsewhere);
|
||||||
|
page.RuleSources.Single(source => source.IsSelected).Id.ShouldBe("other");
|
||||||
|
|
||||||
|
await page.RemoveRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
store.Rules.ShouldBeEmpty();
|
||||||
|
page.HasRule.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Disposing_twice_is_safe()
|
public async Task Disposing_twice_is_safe()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user