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 @@
-