From 155c654cbecbcc3e464151f6b52a228d0d14249c Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 14 Oct 2026 17:26:33 +0200 Subject: [PATCH 1/2] Plugin-Shared: headless batch request dispatch + request type constants PluginBootstrap.Run takes an optional batch handler; a "batch" request (Subtitle Edit Batch convert) is answered without starting Avalonia. Co-Authored-By: Claude Opus 5.5 --- se5/Plugin-Shared/PluginBootstrap.cs | 27 +++++++++++++++++++++++++-- se5/Plugin-Shared/PluginContract.cs | 16 ++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/se5/Plugin-Shared/PluginBootstrap.cs b/se5/Plugin-Shared/PluginBootstrap.cs index 8c83a22b..44ba4676 100644 --- a/se5/Plugin-Shared/PluginBootstrap.cs +++ b/se5/Plugin-Shared/PluginBootstrap.cs @@ -9,7 +9,8 @@ namespace SubtitleEdit.Plugins.Shared; /// /// Entry-point glue for an SE5 plugin: parse the request JSON from the first /// command-line argument, start Avalonia, write the response JSON when the -/// app exits, return 0. +/// app exits, return 0. A "batch" request (Subtitle Edit's Batch convert) is handed to the +/// plugin's batch handler instead, without starting any UI. /// public static class PluginBootstrap { @@ -21,7 +22,12 @@ public static class PluginBootstrap WriteIndented = true, }; - public static int Run(string[] args) where TApp : Application, new() + /// + /// Handles a "batch" request headless: apply saved settings to all lines and return the + /// response. Plugins that pass it should set "batch": true in plugin.json and also + /// handle a "configure" request in their window (settings only, no subtitle in the response). + /// + public static int Run(string[] args, Func? batchHandler = null) where TApp : Application, new() { if (args.Length < 1) { @@ -46,6 +52,23 @@ public static class PluginBootstrap return 1; } + if (request.RequestType == PluginRequestType.Batch) + { + PluginResponse batchResponse; + try + { + batchResponse = batchHandler?.Invoke(request) + ?? new PluginResponse { Status = PluginStatus.Error, Message = "This plugin does not support batch convert." }; + } + catch (Exception exception) + { + batchResponse = new PluginResponse { Status = PluginStatus.Error, Message = exception.Message }; + } + + File.WriteAllText(request.ResponseFilePath, JsonSerializer.Serialize(batchResponse, JsonOpts)); + return 0; + } + PluginApp.PendingRequest = request; BuildAvaloniaApp().StartWithClassicDesktopLifetime(Array.Empty()); diff --git a/se5/Plugin-Shared/PluginContract.cs b/se5/Plugin-Shared/PluginContract.cs index 72b6d045..524a4c8b 100644 --- a/se5/Plugin-Shared/PluginContract.cs +++ b/se5/Plugin-Shared/PluginContract.cs @@ -146,6 +146,22 @@ public sealed class PluginResponse public string? UndoDescription { get; set; } } +/// +/// values. "batch" and "configure" come from Subtitle Edit's +/// Batch convert and are only sent to plugins with "batch": true in plugin.json. +/// +public static class PluginRequestType +{ + /// From the Plugins menu - the plugin may show its window. + public const string Run = "run"; + + /// Once per file in Batch convert - no UI, saved settings, all lines. + public const string Batch = "batch"; + + /// From Batch convert's Configure button - show settings only, return them without a subtitle. + public const string Configure = "configure"; +} + public static class PluginStatus { public const string Ok = "ok"; From 8fddf87ad8cd558d35a3bbe7816eafb9020ee537 Mon Sep 17 00:00:00 2001 From: niksedk Date: Wed, 14 Oct 2026 17:26:33 +0200 Subject: [PATCH 2/2] ArteCheck: support Subtitle Edit Batch convert - plugin.json "batch": true - batch request: apply every correction, re-check up to 5 passes, no UI - configure request: settings-only window; also saves Timed-for frame rate and programme start for batch runs (normal runs keep and ignore them) Co-Authored-By: Claude Opus 5.5 --- se5/ArteCheck.Tests/BatchModeTests.cs | 99 ++++++++++++++++++++++++ se5/ArteCheck/MainViewModel.cs | 107 +++++++++++++++++++++++++- se5/ArteCheck/MainWindow.axaml | 17 +++- se5/ArteCheck/Program.cs | 2 +- se5/ArteCheck/README.md | 8 ++ se5/ArteCheck/plugin.json | 1 + 6 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 se5/ArteCheck.Tests/BatchModeTests.cs diff --git a/se5/ArteCheck.Tests/BatchModeTests.cs b/se5/ArteCheck.Tests/BatchModeTests.cs new file mode 100644 index 00000000..436dfb53 --- /dev/null +++ b/se5/ArteCheck.Tests/BatchModeTests.cs @@ -0,0 +1,99 @@ +using SubtitleEdit.Plugins.ArteCheck; +using SubtitleEdit.Plugins.Shared; +using System.Text.Json; + +namespace ArteCheck.Tests; + +/// Subtitle Edit Batch convert: "batch" applies every correction headless, "configure" returns settings only. +public class BatchModeTests +{ + private const double Hour10 = 10 * 3_600_000.0; + + private static PluginRequest MakeRequest(string requestType, string? settingsJson = null) => new() + { + RequestType = requestType, + Subtitle = new PluginSubtitle + { + Format = "SubRip", + Paragraphs = requestType == PluginRequestType.Configure + ? new List() + : new List + { + new() { StartMs = Hour10 + 1000, EndMs = Hour10 + 4000, Text = " Hello there. " }, + }, + }, + Settings = settingsJson == null ? null : JsonDocument.Parse(settingsJson).RootElement.Clone(), + SettingsVersion = settingsJson == null ? null : 1, + }; + + [Fact] + public void Batch_AppliesCorrections() + { + var response = new MainViewModel(MakeRequest(PluginRequestType.Batch)).BuildBatchResponse(); + + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.NotNull(response.Subtitle?.Paragraphs); + Assert.Contains(response.Subtitle!.Paragraphs!, p => p.Text == "Hello there."); + } + + [Fact] + public void Batch_UsesFrameRateSavedByConfigure() + { + var vm = new MainViewModel(MakeRequest(PluginRequestType.Batch, "{\"batchFrameRate\":29.97}")); + + Assert.Equal(29.97, vm.SelectedFrameRate, 3); + } + + [Fact] + public void Run_IgnoresButKeepsBatchSettings() + { + var vm = new MainViewModel(MakeRequest(PluginRequestType.Run, "{\"batchFrameRate\":29.97,\"batchShiftToStartTimeCode\":true}")); + + Assert.Equal(25.0, vm.SelectedFrameRate, 3); + Assert.False(vm.ShiftToStartTimeCode); + var settings = vm.BuildCancelResponse().Settings!.Value; + Assert.Equal(29.97, settings.GetProperty("batchFrameRate").GetDouble(), 3); + Assert.True(settings.GetProperty("batchShiftToStartTimeCode").GetBoolean()); + } + + [Fact] + public void Configure_ReturnsSettingsWithoutSubtitle() + { + var vm = new MainViewModel(MakeRequest(PluginRequestType.Configure)) + { + SelectedFrameRate = 30.0, + MinimumGapFrames = 4, + }; + + var response = vm.BuildResponse(); + + Assert.Equal(PluginStatus.Ok, response.Status); + Assert.Null(response.Subtitle); + Assert.Equal(1, response.SettingsVersion); + Assert.Equal(30.0, response.Settings!.Value.GetProperty("batchFrameRate").GetDouble(), 3); + Assert.Equal(4, response.Settings.Value.GetProperty("minimumGapFrames").GetInt32()); + } + + [Fact] + public void Bootstrap_BatchRequest_RunsHandlerWithoutUi() + { + var dir = Directory.CreateTempSubdirectory("arte-batch-test"); + try + { + var request = MakeRequest(PluginRequestType.Batch); + request.ResponseFilePath = Path.Combine(dir.FullName, "response.json"); + var requestPath = Path.Combine(dir.FullName, "request.json"); + File.WriteAllText(requestPath, JsonSerializer.Serialize(request, PluginBootstrap.JsonOpts)); + + var exitCode = PluginBootstrap.Run(new[] { requestPath }, r => new MainViewModel(r).BuildBatchResponse()); + + Assert.Equal(0, exitCode); + var response = JsonSerializer.Deserialize(File.ReadAllText(request.ResponseFilePath), PluginBootstrap.JsonOpts)!; + Assert.Equal(PluginStatus.Ok, response.Status); + } + finally + { + dir.Delete(recursive: true); + } + } +} diff --git a/se5/ArteCheck/MainViewModel.cs b/se5/ArteCheck/MainViewModel.cs index 0c3fe59e..ee07145e 100644 --- a/se5/ArteCheck/MainViewModel.cs +++ b/se5/ArteCheck/MainViewModel.cs @@ -24,6 +24,8 @@ public sealed partial class MainViewModel : ObservableObject private readonly PluginRequest _request; private readonly bool _isEbu; private readonly bool _legacyRequest; + private readonly bool _isConfigure; + private readonly bool _isBatch; private readonly long _videoOffsetMs; private readonly Stack<(List Paragraphs, string? Header, double FrameRate, int Applied)> _undo = new(); private readonly Dictionary _expandedGroups = new(); @@ -54,6 +56,8 @@ public sealed partial class MainViewModel : ObservableObject public MainViewModel(PluginRequest request) { _request = request; + _isConfigure = request.RequestType == PluginRequestType.Configure; + _isBatch = request.RequestType == PluginRequestType.Batch; _isEbu = string.Equals(request.Subtitle.Format, EbuFormatName, StringComparison.OrdinalIgnoreCase); _videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0; _legacyRequest = request.Subtitle.Paragraphs == null; @@ -100,23 +104,32 @@ public MainViewModel(PluginRequest request) public IReadOnlyList Checks { get; } public ObservableCollection Groups { get; } = new(); + /// Opened from Batch convert's Configure button: only the settings matter, there is no subtitle. + public bool IsConfigure => _isConfigure; + public bool IsNotConfigure => !_isConfigure; + 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} subtitles"; } } - public bool ShowFormatWarning => !_isEbu; + public bool ShowFormatWarning => !_isEbu && !_isConfigure; public string FormatWarning => _legacyRequest ? "This Subtitle Edit version only sends SubRip to plugins, so teletext rows and the EBU header are not checked. Update Subtitle Edit for the full check." : $"The subtitle is {(_request.Subtitle.Format is { Length: > 0 } f ? f : "not EBU STL")}. Header and teletext row checks need EBU STL - save as EBU STL for delivery."; - public bool HasFindings => FixCount + AlarmCount > 0; - public bool IsClean => !HasFindings; + public bool HasFindings => FixCount + AlarmCount > 0 && !_isConfigure; + public bool IsClean => !HasFindings && !_isConfigure; public bool ShowAll { get => Filter == FindingFilter.All; set { if (value) Filter = FindingFilter.All; } } public bool ShowFixes { get => Filter == FindingFilter.Fixes; set { if (value) Filter = FindingFilter.Fixes; } } public bool ShowAlarms { get => Filter == FindingFilter.Alarms; set { if (value) Filter = FindingFilter.Alarms; } } @@ -396,12 +409,22 @@ public string BuildReport() /// The response for OK: applies what is still selected, then hands the working copy back. public PluginResponse BuildResponse() { + if (_isConfigure) + { + return BuildConfigureResponse(); + } + ApplyPending(); if (AppliedCount == 0) { return new PluginResponse { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; } + return BuildOkResponse(); + } + + private PluginResponse BuildOkResponse() + { var paragraphs = _paragraphs.Select(p => { var copy = p.Clone(); @@ -439,6 +462,39 @@ public PluginResponse BuildResponse() }; } + /// + /// A "batch" request (Batch convert): apply every correction, re-checking until nothing is + /// left to fix (fixes can enable further fixes), and return the result without any UI. + /// + public PluginResponse BuildBatchResponse() + { + for (var pass = 0; pass < MaxBatchPasses && ApplyPending(); pass++) + { + Analyze(); + } + + if (AppliedCount == 0) + { + return new PluginResponse + { + Status = PluginStatus.Ok, + Message = AlarmCount > 0 ? $"ARTE check: no corrections, {AlarmCount} alarm(s)." : "ARTE check: no issues found.", + }; + } + + var response = BuildOkResponse(); + response.Message = AlarmCount > 0 + ? $"ARTE check: {AppliedCount} correction(s) applied, {AlarmCount} alarm(s) need a manual edit." + : response.Message; + return response; + } + + private const int MaxBatchPasses = 5; + + /// 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 }; + public PluginResponse BuildCancelResponse() => new() { Status = PluginStatus.Cancelled, Settings = BuildSettings(), SettingsVersion = SettingsVersion }; @@ -453,9 +509,34 @@ private JsonElement BuildSettings() ["shortMinimumFrames"] = (int)(ShortMinimumFrames ?? ArteOptions.PresetShortMinimumFrames), ["disabledChecks"] = Checks.Where(c => !c.IsEnabled).Select(c => c.Type.ToString()).ToArray(), }; + + // "Timed for" and the start time code describe one file, so a normal run never stores + // them - but Batch convert has no window to ask in, so Configure saves them for batch runs. + // A normal run keeps whatever Configure saved. + if (_isConfigure) + { + payload[BatchFrameRateKey] = SelectedFrameRate; + payload[BatchShiftToStartTimeCodeKey] = ShiftToStartTimeCode; + payload[BatchTargetStartTimeCodeKey] = TargetStartTimeCode; + } + else if (_request.SettingsVersion == SettingsVersion && _request.Settings is { ValueKind: JsonValueKind.Object } stored) + { + foreach (var key in new[] { BatchFrameRateKey, BatchShiftToStartTimeCodeKey, BatchTargetStartTimeCodeKey }) + { + if (stored.TryGetProperty(key, out var value)) + { + payload[key] = value.Clone(); + } + } + } + return JsonSerializer.SerializeToElement(payload); } + private const string BatchFrameRateKey = "batchFrameRate"; + private const string BatchShiftToStartTimeCodeKey = "batchShiftToStartTimeCode"; + private const string BatchTargetStartTimeCodeKey = "batchTargetStartTimeCode"; + private void LoadSettings() { if (_request.SettingsVersion != SettingsVersion || _request.Settings is not { ValueKind: JsonValueKind.Object } settings) @@ -463,6 +544,26 @@ private void LoadSettings() return; } + if (_isConfigure || _isBatch) + { + if (settings.TryGetProperty(BatchFrameRateKey, out var frameRate) && frameRate.TryGetDouble(out var fps) && + FrameRates.Any(f => Math.Abs(f - fps) < 0.001)) + { + SelectedFrameRate = fps; + } + + if (settings.TryGetProperty(BatchShiftToStartTimeCodeKey, out var shift) && shift.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + ShiftToStartTimeCode = shift.GetBoolean(); + } + + if (settings.TryGetProperty(BatchTargetStartTimeCodeKey, out var target) && target.GetString() is { } tc && + ArteChecker.TryParseTimeCode(tc, out _)) + { + TargetStartTimeCode = tc; + } + } + if (settings.TryGetProperty("teletextMaxCells", out var cells) && cells.TryGetInt32(out var c) && c is >= 10 and <= 40) { TeletextMaxCells = c; diff --git a/se5/ArteCheck/MainWindow.axaml b/se5/ArteCheck/MainWindow.axaml index 40504313..18e2acf8 100644 --- a/se5/ArteCheck/MainWindow.axaml +++ b/se5/ArteCheck/MainWindow.axaml @@ -105,7 +105,7 @@ - + @@ -137,13 +137,13 @@ - -