From cbdf3b152e57d0e9ca4879f1144328d7248102d9 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 14 Oct 2026 19:04:40 +0200 Subject: [PATCH] PersianErrors: support Subtitle Edit Batch convert - plugin.json "batch": true - batch request: run the saved enabled groups over every line and keep every fix (OK with the default selection, one pass), no UI - configure request: settings-only window (group sidebar), settings returned without subtitle; remembered scope/display options are kept Co-Authored-By: Claude Opus 5.5 --- se5/PersianErrors.Tests/BatchModeTests.cs | 83 +++++++++++++++++++++++ se5/PersianErrors/MainViewModel.cs | 63 +++++++++++++++-- se5/PersianErrors/MainWindow.axaml | 22 ++++-- se5/PersianErrors/Program.cs | 2 +- se5/PersianErrors/README.md | 11 +++ se5/PersianErrors/plugin.json | 1 + 6 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 se5/PersianErrors.Tests/BatchModeTests.cs diff --git a/se5/PersianErrors.Tests/BatchModeTests.cs b/se5/PersianErrors.Tests/BatchModeTests.cs new file mode 100644 index 00000000..1af6965d --- /dev/null +++ b/se5/PersianErrors.Tests/BatchModeTests.cs @@ -0,0 +1,83 @@ +using SubtitleEdit.Plugins.PersianErrors; +using SubtitleEdit.Plugins.Shared; +using System.Text.Json; + +namespace PersianErrors.Tests; + +/// Subtitle Edit Batch convert: "batch" fixes every line headless, "configure" returns the groups only. +public class BatchModeTests +{ + private static PluginRequest Request(string requestType, string? settingsJson, params string[] texts) => new() + { + RequestType = requestType, + Subtitle = new PluginSubtitle + { + Format = "SubRip", + Paragraphs = texts.Select((t, i) => new PluginParagraph { StartMs = i * 1000, EndMs = i * 1000 + 900, Text = t }).ToList(), + }, + Settings = settingsJson == null ? null : JsonDocument.Parse(settingsJson).RootElement.Clone(), + SettingsVersion = settingsJson == null ? null : 1, + }; + + [Fact] + public void Batch_FixesAllLines() + { + var response = new MainViewModel(Request(PluginRequestType.Batch, null, "شدني", "درست", "املاك")).BuildBatchResponse(); + + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Equal(new[] { "شدنی", "درست", "املاک" }, response.Subtitle!.Paragraphs!.Select(p => p.Text)); + Assert.Null(response.Settings); + Assert.Null(response.SettingsVersion); + } + + [Fact] + public void Batch_NothingToFix_ReturnsOkWithoutSubtitle() + { + var response = new MainViewModel(Request(PluginRequestType.Batch, null, "درست")).BuildBatchResponse(); + + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + } + + [Fact] + public void Batch_SkipsDisabledGroups() + { + var settings = "{\"disabledGroups\":[\"Change Arabic Chars to Persian\"]}"; + + var response = new MainViewModel(Request(PluginRequestType.Batch, settings, "شدني")).BuildBatchResponse(); + + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + } + + [Fact] + public void Batch_IgnoresSelectedLinesOnly() + { + var request = Request(PluginRequestType.Batch, "{\"selectedLinesOnly\":true}", "شدني", "املاك"); + request.SelectedIndices = new List { 0 }; + + var response = new MainViewModel(request).BuildBatchResponse(); + + Assert.Equal(new[] { "شدنی", "املاک" }, response.Subtitle!.Paragraphs!.Select(p => p.Text)); + } + + [Fact] + public void Configure_ReturnsSettingsWithoutSubtitle() + { + var vm = new MainViewModel(Request(PluginRequestType.Configure, "{\"selectedLinesOnly\":true,\"showHidden\":false}")); + Assert.True(vm.IsConfigure); + Assert.False(vm.IsClean); + Assert.False(vm.IsBusy); + vm.Groups.Single(g => g.Name == "OCR").IsEnabled = false; + + var response = vm.BuildResponse(); + + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + Assert.Equal(1, response.SettingsVersion); + var settings = response.Settings!.Value; + Assert.Equal(new[] { "OCR" }, settings.GetProperty("disabledGroups").EnumerateArray().Select(e => e.GetString())); + Assert.True(settings.GetProperty("selectedLinesOnly").GetBoolean()); + Assert.False(settings.GetProperty("showHidden").GetBoolean()); + } +} diff --git a/se5/PersianErrors/MainViewModel.cs b/se5/PersianErrors/MainViewModel.cs index 249bd0ba..d3f5ab7d 100644 --- a/se5/PersianErrors/MainViewModel.cs +++ b/se5/PersianErrors/MainViewModel.cs @@ -17,10 +17,13 @@ public sealed partial class MainViewModel : ObservableObject private readonly string[] _originalTexts; private readonly string[] _texts; private readonly long _videoOffsetMs; + private readonly bool _isConfigure; + private readonly bool _isBatch; private readonly Stack _undo = new(); private List _fixes = new(); private CancellationTokenSource? _analysisCancellation; private bool _suspendAnalysis; + private bool _savedSelectedLinesOnly; [ObservableProperty] private bool _isBusy; [ObservableProperty] private double _progress; @@ -36,6 +39,8 @@ public sealed partial class MainViewModel : ObservableObject public MainViewModel(PluginRequest request) { _request = request; + _isConfigure = request.RequestType == PluginRequestType.Configure; + _isBatch = request.RequestType == PluginRequestType.Batch; _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; _legacyRequest = request.Subtitle.Paragraphs == null; _paragraphs = _legacyRequest @@ -55,6 +60,7 @@ public MainViewModel(PluginRequest request) if (e.PropertyName == nameof(GroupOption.IsEnabled)) { OnPropertyChanged(nameof(EnabledGroupCount)); + OnPropertyChanged(nameof(StatusText)); Analyze(); } }; @@ -77,12 +83,21 @@ public string SubtitleInfo { get { + if (_isConfigure) + { + return "Settings for Batch convert"; + } + var name = string.IsNullOrEmpty(_request.Subtitle.FileName) ? "Untitled" : Path.GetFileName(_request.Subtitle.FileName); var format = string.IsNullOrEmpty(_request.Subtitle.Format) ? string.Empty : $" · {_request.Subtitle.Format}"; return $"{name}{format} · {_paragraphs.Count:N0} lines"; } } + /// Opened from Batch convert's Configure button: only the groups matter, there is no subtitle. + public bool IsConfigure => _isConfigure; + public bool IsNotConfigure => !_isConfigure; + public int EnabledGroupCount => Groups.Count(g => g.IsEnabled); public int TotalRuleCount => Groups.Sum(g => g.Group.Rules.Count); public bool HasSelectedLines => SelectedIndices.Count > 0; @@ -90,7 +105,7 @@ public string SubtitleInfo public bool AllLines { get => !SelectedLinesOnly; set => SelectedLinesOnly = !value; } public bool HasFixes => FixCount > 0; public bool HasVisibleFixes => VisibleFixes.Count > 0; - public bool IsClean => !IsBusy && FixCount == 0; + public bool IsClean => !IsBusy && FixCount == 0 && !_isConfigure; public bool IsFilteredEmpty => !IsBusy && FixCount > 0 && VisibleFixes.Count == 0; public bool HasChanges => ChangedLineCount > 0; public bool CanApply => !IsBusy && SelectedCount > 0; @@ -103,6 +118,11 @@ public string StatusText { get { + if (_isConfigure) + { + return EnabledGroupCount == 1 ? "1 group used for every file in Batch convert" : $"{EnabledGroupCount:N0} groups used for every file in Batch convert"; + } + if (IsBusy) { return $"Checking {LinesInScope:N0} lines against {Groups.Where(g => g.IsEnabled).Sum(g => g.Group.Rules.Count):N0} rules..."; @@ -152,7 +172,7 @@ private void RaiseState() private async void Analyze() { - if (_suspendAnalysis) + if (_suspendAnalysis || _isConfigure || _isBatch) { return; } @@ -339,9 +359,42 @@ private int ApplySelected() private void UpdateChangedLineCount() => ChangedLineCount = _texts.Where((t, i) => !string.Equals(t, _originalTexts[i], StringComparison.Ordinal)).Count(); + /// + /// A "batch" request (Batch convert): run the saved enabled groups over every line, without UI, + /// and keep every fix - what OK does with the default selection. No fixes is "ok" without a subtitle. + /// + public PluginResponse BuildBatchResponse() + { + var groups = Groups.Where(g => g.IsEnabled).Select(g => g.Group).ToList(); + foreach (var fix in PersianFixer.FindFixes(_texts, Enumerable.Range(0, _texts.Length), groups)) + { + _texts[fix.Index] = fix.After; + } + + UpdateChangedLineCount(); + if (ChangedLineCount == 0) + { + return new PluginResponse { Status = PluginStatus.Ok, Message = "Persian Subtitle Fixes: nothing to fix." }; + } + + var response = BuildResponse(); + response.Settings = null; + response.SettingsVersion = null; + return response; + } + + /// A "configure" request: hand back the settings only, for the next Batch convert run. + public PluginResponse BuildConfigureResponse() => + new() { Status = PluginStatus.Ok, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; + /// The response for OK: applies what is still selected, then hands the lines back. public PluginResponse BuildResponse() { + if (_isConfigure) + { + return BuildConfigureResponse(); + } + if (!IsBusy) { ApplySelected(); @@ -400,7 +453,8 @@ private JsonElement BuildSettings() { ["disabledGroups"] = Groups.Where(g => !g.IsEnabled).Select(g => g.Name).ToArray(), ["showHidden"] = ShowHidden, - ["selectedLinesOnly"] = SelectedLinesOnly, + // Configure has no selection: keep the scope the window remembered. + ["selectedLinesOnly"] = _isConfigure ? _savedSelectedLinesOnly : SelectedLinesOnly, }; return JsonSerializer.SerializeToElement(payload); } @@ -428,7 +482,8 @@ private void LoadSettings() if (settings.TryGetProperty("selectedLinesOnly", out var selectedOnly) && selectedOnly.ValueKind is JsonValueKind.True or JsonValueKind.False) { - SelectedLinesOnly = selectedOnly.GetBoolean() && HasSelectedLines; + _savedSelectedLinesOnly = selectedOnly.GetBoolean(); + SelectedLinesOnly = _savedSelectedLinesOnly && HasSelectedLines; } } diff --git a/se5/PersianErrors/MainWindow.axaml b/se5/PersianErrors/MainWindow.axaml index 4fb40e00..f2436048 100644 --- a/se5/PersianErrors/MainWindow.axaml +++ b/se5/PersianErrors/MainWindow.axaml @@ -146,7 +146,7 @@ - + @@ -181,13 +181,14 @@ - -