139 lines
5.5 KiB
C#
139 lines
5.5 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using System.Text.Json;
|
|
|
|
namespace VideoDownloader.App.Browser.Features;
|
|
|
|
/// <summary>팔레트 명령(T2-1, Arc Command Bar / Vivaldi Quick Commands 패턴).</summary>
|
|
public sealed record PaletteCommand(string Title, string Keywords, Action Execute, string? Category = null);
|
|
|
|
/// <summary>명령 등록·검색·실행. 동적 소스(탭/기록/북마크)는 검색 시점에 평가.</summary>
|
|
public sealed class CommandPalette
|
|
{
|
|
private readonly List<PaletteCommand> _static = new();
|
|
private readonly List<Func<IEnumerable<PaletteCommand>>> _dynamic = new();
|
|
private readonly List<PaletteCommand> _separators = new(); // 빈질의 표시용 구분자(Title="\u2015")
|
|
|
|
public IReadOnlyList<PaletteCommand> Register(string title, string keywords, Action execute, string? category = null)
|
|
{
|
|
var cmd = new PaletteCommand(title, keywords, execute, category);
|
|
_static.Add(cmd);
|
|
return new[] { cmd };
|
|
}
|
|
|
|
public void RegisterDynamic(Func<List<PaletteCommand>> source) => _dynamic.Add(source);
|
|
|
|
public void RegisterSeparator() => _separators.Add(new PaletteCommand("\u2015", "", () => { }));
|
|
|
|
/// <summary>검색: 정적+동적 통합, 시작-일치 우선, 대소문자 무시. 빈 질의는 정적 전체(등록순).</summary>
|
|
public List<PaletteCommand> Search(string? query)
|
|
{
|
|
query = query?.Trim();
|
|
if (string.IsNullOrEmpty(query))
|
|
return _static.ToList();
|
|
|
|
var low = query.ToLowerInvariant();
|
|
var pool = _static.Concat(_dynamic.SelectMany(d => d())).ToList();
|
|
|
|
return pool
|
|
.Select(c => new { Cmd = c, Hay = (c.Title + " " + c.Keywords).ToLowerInvariant() })
|
|
.Where(x => x.Hay.Contains(low))
|
|
.OrderByDescending(x => x.Hay.StartsWith(low) ? 1 : 0)
|
|
.ThenBy(_ => 0) // 안정 정렬 유지(등록순)
|
|
.Select(x => x.Cmd)
|
|
.Take(12)
|
|
.ToList();
|
|
}
|
|
|
|
public void Execute(PaletteCommand command) => command.Execute();
|
|
}
|
|
|
|
/// <summary>페이지 액션 필터(T3-6, Vivaldi Page Actions — Boost 통합).</summary>
|
|
public enum PageFilter { None, Grayscale, Sepia }
|
|
|
|
/// <summary>사이트별 부스트 설정(T2-7, Zen/Arc Boosts 패턴) + 페이지 액션 필터(T3-6).</summary>
|
|
public sealed record BoostSettings(bool ForceDark, string CustomCss, PageFilter Filter = PageFilter.None);
|
|
|
|
/// <summary>도메인별 부스트 영속 저장.</summary>
|
|
public sealed class BoostStore
|
|
{
|
|
private readonly string _path;
|
|
private Dictionary<string, BoostSettings> _map = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public BoostStore(string path)
|
|
{
|
|
_path = path;
|
|
try
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
var loaded = JsonSerializer.Deserialize<Dictionary<string, BoostSettings>>(File.ReadAllText(path));
|
|
if (loaded != null) _map = loaded;
|
|
}
|
|
}
|
|
catch { _map = new(); }
|
|
}
|
|
|
|
public BoostSettings Get(string? url)
|
|
{
|
|
var host = Normalize(url);
|
|
return host != null && _map.TryGetValue(host, out var v) ? v : new BoostSettings(false, "");
|
|
}
|
|
|
|
public void Set(string? url, bool forceDark, string css, PageFilter filter = PageFilter.None)
|
|
{
|
|
var host = Normalize(url);
|
|
if (host == null) return;
|
|
_map[host] = new BoostSettings(forceDark, css, filter);
|
|
try
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
|
var tmp = _path + ".tmp";
|
|
File.WriteAllText(tmp, JsonSerializer.Serialize(_map));
|
|
File.Move(tmp, _path, overwrite: true);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
private static string? Normalize(string? url)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(url)) return null;
|
|
var raw = url.Trim();
|
|
Uri? uri = null;
|
|
if (Uri.TryCreate(raw, UriKind.Absolute, out var parsed) && !string.IsNullOrEmpty(parsed.Host)) uri = parsed;
|
|
else Uri.TryCreate("https://" + raw, UriKind.Absolute, out uri); // 스킴 없는 호스트 허용
|
|
if (uri == null) return null;
|
|
var host = uri.Host;
|
|
return host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host;
|
|
}
|
|
}
|
|
|
|
/// <summary>부스트 → 문서 생성 시점 주입 JS 생성(강제 다크: 반전 필터 + 미디어 보호).</summary>
|
|
public static class BoostCssBuilder
|
|
{
|
|
public static string BuildScript(string? url, BoostSettings settings)
|
|
{
|
|
if (!settings.ForceDark && settings.Filter == PageFilter.None && string.IsNullOrWhiteSpace(settings.CustomCss)) return "";
|
|
var css = "";
|
|
if (settings.ForceDark)
|
|
{
|
|
css += """
|
|
html { filter: invert(1) hue-rotate(180deg); background: #fff; }
|
|
img, video, picture, canvas, svg, iframe, embed, object { filter: invert(1) hue-rotate(180deg); }
|
|
""";
|
|
}
|
|
else if (settings.Filter == PageFilter.Grayscale) // T3-6 페이지 액션
|
|
{
|
|
css += "html { filter: grayscale(1); }";
|
|
}
|
|
else if (settings.Filter == PageFilter.Sepia)
|
|
{
|
|
css += "html { filter: sepia(0.7); }";
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(settings.CustomCss)) css += settings.CustomCss + "\n";
|
|
var js = $$"""
|
|
(function(){ try { var s = document.createElement('style'); s.id = 'vd-boost'; s.textContent = {{JsonSerializer.Serialize(css)}}; (document.head || document.documentElement).appendChild(s); } catch(e){} })();
|
|
""";
|
|
return js;
|
|
}
|
|
}
|