diff --git a/se5/FixEncodingErrors.Tests/BatchModeTests.cs b/se5/FixEncodingErrors.Tests/BatchModeTests.cs
new file mode 100644
index 00000000..3abf88b5
--- /dev/null
+++ b/se5/FixEncodingErrors.Tests/BatchModeTests.cs
@@ -0,0 +1,63 @@
+using SubtitleEdit.Plugins.FixEncodingErrors;
+using SubtitleEdit.Plugins.Shared;
+using System.Text.Json;
+
+namespace FixEncodingErrors.Tests;
+
+/// Subtitle Edit Batch convert: "batch" fixes every line headless, "configure" returns the code page 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_FixesAllBrokenLines()
+ {
+ var response = new MainViewModel(Request(PluginRequestType.Batch, null, "café", "Plain", "don’t")).BuildBatchResponse();
+
+ Assert.Equal(PluginStatus.Ok, response.Status);
+ Assert.Equal(new[] { "café", "Plain", "don’t" }, response.Subtitle!.Paragraphs!.Select(p => p.Text));
+ Assert.Null(response.Settings);
+ }
+
+ [Fact]
+ public void Batch_NothingToFix_ReturnsOkWithoutSubtitle()
+ {
+ var response = new MainViewModel(Request(PluginRequestType.Batch, null, "Plain", "résumé")).BuildBatchResponse();
+
+ Assert.Equal(PluginStatus.Ok, response.Status);
+ Assert.Null(response.Subtitle);
+ }
+
+ [Fact]
+ public void Batch_UsesSavedCodePage()
+ {
+ var vm = new MainViewModel(Request(PluginRequestType.Batch, "{\"codePage\":1251}", "café"));
+
+ Assert.Equal(1251, vm.SelectedEncoding.CodePage);
+ }
+
+ [Fact]
+ public void Configure_ReturnsSettingsWithoutSubtitle()
+ {
+ var vm = new MainViewModel(Request(PluginRequestType.Configure, null));
+ Assert.True(vm.CanApply);
+ Assert.False(vm.IsClean);
+ vm.SelectedEncoding = vm.EncodingOptions.First(e => e.CodePage == 1250);
+
+ var response = vm.BuildResponse();
+
+ Assert.Equal(PluginStatus.Ok, response.Status);
+ Assert.Null(response.Subtitle);
+ Assert.Equal(1250, response.Settings!.Value.GetProperty("codePage").GetInt32());
+ }
+}
diff --git a/se5/FixEncodingErrors/MainViewModel.cs b/se5/FixEncodingErrors/MainViewModel.cs
index f8232a4b..e277f55b 100644
--- a/se5/FixEncodingErrors/MainViewModel.cs
+++ b/se5/FixEncodingErrors/MainViewModel.cs
@@ -16,6 +16,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly List _paragraphs;
private readonly long _videoOffsetMs;
private readonly SourceEncoding _detected;
+ private readonly bool _isConfigure;
private bool _loading;
[ObservableProperty] private SourceEncoding _selectedEncoding;
@@ -25,6 +26,7 @@ public sealed partial class MainViewModel : ObservableObject
public MainViewModel(PluginRequest request)
{
_request = request;
+ _isConfigure = request.RequestType == PluginRequestType.Configure;
_videoOffsetMs = request.Rules?.VideoOffsetMs ?? 0;
_legacyRequest = request.Subtitle.Paragraphs == null;
_paragraphs = _legacyRequest
@@ -34,7 +36,7 @@ public MainViewModel(PluginRequest request)
SelectedIndices = request.SelectedIndices.Where(i => i >= 0 && i < _paragraphs.Count).Distinct().Order().ToList();
var detected = EncodingFixer.Detect(_paragraphs.Select(p => p.Text ?? string.Empty));
- AutoEncoding = new SourceEncoding(0, "Auto-detect - " + detected.Name);
+ AutoEncoding = new SourceEncoding(0, _isConfigure ? "Auto-detect (per file)" : "Auto-detect - " + detected.Name);
_detected = detected;
EncodingOptions = new[] { AutoEncoding }.Concat(EncodingFixer.Encodings).ToList();
_selectedEncoding = AutoEncoding;
@@ -57,22 +59,33 @@ 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 code page matters, there is no subtitle.
+ public bool IsConfigure => _isConfigure;
+ public bool IsNotConfigure => !_isConfigure;
+
public bool HasSelectedLines => SelectedIndices.Count > 0;
public string SelectedLinesText => SelectedIndices.Count == 1 ? "Selected line (1)" : $"Selected lines ({SelectedIndices.Count:N0})";
public bool AllLines { get => !SelectedLinesOnly; set => SelectedLinesOnly = !value; }
public int ChangeCount => Changes.Count;
public bool HasChanges => Changes.Count > 0;
- public bool IsClean => Changes.Count == 0;
- public bool CanApply => SelectedCount > 0;
+ public bool IsClean => Changes.Count == 0 && !_isConfigure;
+ public bool CanApply => SelectedCount > 0 || _isConfigure;
public string CleanText => $"No broken characters found for {EffectiveEncoding.Name}.";
- public string StatusText => Changes.Count == 0
+ public string StatusText => _isConfigure
+ ? "Used for every file in Batch convert"
+ : Changes.Count == 0
? $"{LinesInScope:N0} lines checked"
: $"{SelectedCount:N0} of {Changes.Count:N0} lines selected";
@@ -154,9 +167,35 @@ private void SetSelection(Func selected)
}
}
+ ///
+ /// A "batch" request (Batch convert): fix every line with the saved code page (auto-detect
+ /// per file by default), without UI. No changes is "ok" without a subtitle.
+ ///
+ public PluginResponse BuildBatchResponse()
+ {
+ if (Changes.Count == 0)
+ {
+ return new PluginResponse { Status = PluginStatus.Ok, Message = "Fix encoding errors: 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: the selected lines, fixed.
public PluginResponse BuildResponse()
{
+ if (_isConfigure)
+ {
+ return BuildConfigureResponse();
+ }
+
var selected = Changes.Where(c => c.IsSelected).ToList();
if (selected.Count == 0)
{
diff --git a/se5/FixEncodingErrors/MainWindow.axaml b/se5/FixEncodingErrors/MainWindow.axaml
index acbf304b..618779b6 100644
--- a/se5/FixEncodingErrors/MainWindow.axaml
+++ b/se5/FixEncodingErrors/MainWindow.axaml
@@ -237,7 +237,7 @@
-
+
@@ -276,6 +276,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/se5/FixEncodingErrors/Program.cs b/se5/FixEncodingErrors/Program.cs
index e7c28d11..184452ec 100644
--- a/se5/FixEncodingErrors/Program.cs
+++ b/se5/FixEncodingErrors/Program.cs
@@ -11,6 +11,6 @@ public static class Program
public static int Main(string[] args)
{
IconProvider.Current.Register();
- return PluginBootstrap.Run(args);
+ return PluginBootstrap.Run(args, request => new MainViewModel(request).BuildBatchResponse());
}
}
diff --git a/se5/FixEncodingErrors/README.md b/se5/FixEncodingErrors/README.md
index ae7a7896..b2798f63 100644
--- a/se5/FixEncodingErrors/README.md
+++ b/se5/FixEncodingErrors/README.md
@@ -66,6 +66,13 @@ Works on the lines exactly as Subtitle Edit holds them, so styles, actors and
other fields of formats like ASSA are kept (falls back to SubRip on older SE
versions).
+## Batch convert
+
+Fix encoding errors supports Subtitle Edit's **Batch convert** (Tools → Batch convert → Run plugin; needs a Subtitle Edit version with plugin batch support).
+
+- **Configure...** opens the window without a subtitle to pick the code page. *Auto-detect* (the default) checks each file on its own.
+- During the batch every broken line is fixed without showing the window. Files with nothing to fix are left as they are. The same false-fix guards apply as in the window.
+
## Build
See `.github/workflows/fix-encoding-errors.yml`.
diff --git a/se5/FixEncodingErrors/plugin.json b/se5/FixEncodingErrors/plugin.json
index 647a3bc5..116efec4 100644
--- a/se5/FixEncodingErrors/plugin.json
+++ b/se5/FixEncodingErrors/plugin.json
@@ -7,6 +7,7 @@
"url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/FixEncodingErrors",
"menu": "Tools",
"minSeVersion": "5.0.0",
+ "batch": true,
"runtime": "dotnet",
"entry": "FixEncodingErrors.dll"
}
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";