diff --git a/.github/workflows/word-censor.yml b/.github/workflows/word-censor.yml
index e0238e0f..88c50f42 100644
--- a/.github/workflows/word-censor.yml
+++ b/.github/workflows/word-censor.yml
@@ -5,11 +5,13 @@ on:
branches: [main]
paths:
- 'se5/WordCensor/**'
+ - 'se5/WordCensor.Tests/**'
- 'se5/Plugin-Shared/**'
- '.github/workflows/word-censor.yml'
pull_request:
paths:
- 'se5/WordCensor/**'
+ - 'se5/WordCensor.Tests/**'
- 'se5/Plugin-Shared/**'
- '.github/workflows/word-censor.yml'
workflow_dispatch:
@@ -90,8 +92,22 @@ jobs:
echo "ref=$ref" >> "$GITHUB_OUTPUT"
echo "release=$release_flag" >> "$GITHUB_OUTPUT"
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Test
+ run: dotnet test se5/WordCensor.Tests/WordCensor.Tests.csproj -c Release
+
build:
- needs: prepare
+ needs: [prepare, test]
runs-on: ubuntu-latest
strategy:
fail-fast: false
diff --git a/se5/WordCensor.Tests/BatchModeTests.cs b/se5/WordCensor.Tests/BatchModeTests.cs
new file mode 100644
index 00000000..9a2523b9
--- /dev/null
+++ b/se5/WordCensor.Tests/BatchModeTests.cs
@@ -0,0 +1,87 @@
+using SubtitleEdit.Plugins.Shared;
+using SubtitleEdit.Plugins.WordCensor;
+using System.Text.Json;
+
+namespace WordCensor.Tests;
+
+/// Subtitle Edit Batch convert: "batch" censors every line headless with the saved settings.
+public class BatchModeTests
+{
+ private static PluginRequest Request(string? settingsJson, params string[] texts) => new()
+ {
+ RequestType = PluginRequestType.Batch,
+ Subtitle = new PluginSubtitle
+ {
+ Format = "SubRip",
+ SubRip = SubRipParser.Serialize(texts.Select((t, i) => new SrtBlock
+ {
+ StartMs = i * 1000,
+ EndMs = i * 1000 + 900,
+ Text = t,
+ }).ToList()),
+ },
+ Settings = settingsJson == null ? null : JsonDocument.Parse(settingsJson).RootElement.Clone(),
+ };
+
+ private static List Texts(PluginResponse response) =>
+ SubRipParser.Parse(response.Subtitle!.Native).Select(b => b.Text).ToList();
+
+ [Fact]
+ public void Batch_DefaultSettings_GrawlixOnAllLines()
+ {
+ var response = BatchMode.BuildBatchResponse(Request(null, "Oh shit.", "Plain line", "Damn it"));
+
+ Assert.Equal(PluginStatus.Ok, response.Status);
+ Assert.Null(response.Settings);
+ var texts = Texts(response);
+ Assert.Equal(3, texts.Count);
+ Assert.DoesNotContain("shit", texts[0]);
+ Assert.EndsWith("it.", texts[0]);
+ Assert.Equal("Plain line", texts[1]);
+ Assert.DoesNotContain("Damn", texts[2]);
+ Assert.EndsWith("mn it", texts[2]);
+ }
+
+ [Fact]
+ public void Batch_NothingToCensor_ReturnsOkWithoutSubtitle()
+ {
+ var response = BatchMode.BuildBatchResponse(Request(null, "Hello there", "Nice day"));
+
+ Assert.Equal(PluginStatus.Ok, response.Status);
+ Assert.Null(response.Subtitle);
+ }
+
+ [Fact]
+ public void Batch_UsesSavedReplacementAndColor()
+ {
+ var response = BatchMode.BuildBatchResponse(Request(
+ "{\"mode\":\"Replacement\",\"replacement\":\"[beep]\",\"colorRed\":true}", "What the fuck?"));
+
+ Assert.Equal(new[] { "What the [beep]?" }, Texts(response));
+ }
+
+ [Theory]
+ [InlineData("Grawlix")]
+ [InlineData("Alternative")]
+ public void Batch_RandomModes_AreReproducible(string mode)
+ {
+ var settings = $"{{\"mode\":\"{mode}\"}}";
+ var first = BatchMode.BuildBatchResponse(Request(settings, "Shit, shit, shit.", "Damn fuck damn"));
+ var second = BatchMode.BuildBatchResponse(Request(settings, "Shit, shit, shit.", "Damn fuck damn"));
+
+ Assert.Equal(Texts(first), Texts(second));
+ Assert.NotEqual("Shit, shit, shit.", Texts(first)[0]);
+ }
+
+ [Fact]
+ public void Settings_RoundTrip()
+ {
+ var options = new CensorOptions { Mode = CensorMode.Alternative, Replacement = "***", ColorRed = true };
+
+ var loaded = CensorSettings.Load(CensorSettings.Build(options));
+
+ Assert.Equal(CensorMode.Alternative, loaded.Mode);
+ Assert.Equal("***", loaded.Replacement);
+ Assert.True(loaded.ColorRed);
+ }
+}
diff --git a/se5/WordCensor.Tests/WordCensor.Tests.csproj b/se5/WordCensor.Tests/WordCensor.Tests.csproj
new file mode 100644
index 00000000..2fd07171
--- /dev/null
+++ b/se5/WordCensor.Tests/WordCensor.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/se5/WordCensor/BatchMode.cs b/se5/WordCensor/BatchMode.cs
new file mode 100644
index 00000000..6400d35b
--- /dev/null
+++ b/se5/WordCensor/BatchMode.cs
@@ -0,0 +1,62 @@
+using SubtitleEdit.Plugins.Shared;
+using System;
+
+namespace SubtitleEdit.Plugins.WordCensor;
+
+///
+/// Subtitle Edit's Batch convert ("batch" request): censor every line with the saved settings,
+/// without UI. All lines are changed, like the window's default where every change is checked.
+///
+public static class BatchMode
+{
+ public static PluginResponse BuildBatchResponse(PluginRequest request)
+ {
+ var options = CensorSettings.Load(request.Settings);
+ var blocks = SubRipParser.Parse(request.Subtitle.SubRip);
+
+ // Grawlix and "random alternative" are random by design. Seed from the file's text so a
+ // batch run is reproducible: the same file with the same settings gives the same output.
+ var engine = new WordCensorEngine(StableSeed(request.Subtitle.SubRip));
+
+ var changed = 0;
+ foreach (var block in blocks)
+ {
+ if (engine.TryCensor(block.Text, options, out var censored))
+ {
+ block.Text = censored;
+ changed++;
+ }
+ }
+
+ if (changed == 0)
+ {
+ return new PluginResponse { Status = PluginStatus.Ok, Message = "Word censor: no offensive words found." };
+ }
+
+ return new PluginResponse
+ {
+ Status = PluginStatus.Ok,
+ Message = $"Censored words in {changed} line(s).",
+ UndoDescription = "Word censor",
+ Subtitle = new PluginSubtitle
+ {
+ Format = "SubRip",
+ Native = SubRipParser.Serialize(blocks),
+ },
+ };
+ }
+
+ /// FNV-1a over the text (string.GetHashCode is randomized per process).
+ private static int StableSeed(string? text)
+ {
+ unchecked
+ {
+ var hash = 2166136261u;
+ foreach (var c in text ?? string.Empty)
+ {
+ hash = (hash ^ c) * 16777619u;
+ }
+ return (int)hash;
+ }
+ }
+}
diff --git a/se5/WordCensor/CensorSettings.cs b/se5/WordCensor/CensorSettings.cs
new file mode 100644
index 00000000..3f62de2e
--- /dev/null
+++ b/se5/WordCensor/CensorSettings.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Text.Json;
+
+namespace SubtitleEdit.Plugins.WordCensor;
+
+/// The saved plugin settings (mode, replacement text, red highlight) shared by the window and Batch convert.
+public static class CensorSettings
+{
+ public const string DefaultReplacement = "[censored]";
+ public const int SettingsVersion = 1;
+
+ public static CensorOptions Load(JsonElement? settings)
+ {
+ var options = new CensorOptions { Mode = CensorMode.Grawlix, Replacement = DefaultReplacement, ColorRed = false };
+
+ if (settings is null || settings.Value.ValueKind != JsonValueKind.Object)
+ {
+ return options;
+ }
+
+ var root = settings.Value;
+
+ if (root.TryGetProperty("mode", out var modeProp) && modeProp.ValueKind == JsonValueKind.String &&
+ Enum.TryParse(modeProp.GetString(), ignoreCase: true, out var parsed))
+ {
+ options.Mode = parsed;
+ }
+ if (root.TryGetProperty("replacement", out var rep) && rep.ValueKind == JsonValueKind.String)
+ {
+ var s = rep.GetString();
+ if (!string.IsNullOrEmpty(s))
+ {
+ options.Replacement = s;
+ }
+ }
+ if (root.TryGetProperty("colorRed", out var cr) && cr.ValueKind == JsonValueKind.True)
+ {
+ options.ColorRed = true;
+ }
+
+ return options;
+ }
+
+ public static JsonElement Build(CensorOptions options)
+ {
+ var dto = new
+ {
+ mode = options.Mode.ToString(),
+ replacement = options.Replacement,
+ colorRed = options.ColorRed,
+ };
+ var json = JsonSerializer.Serialize(dto);
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+}
diff --git a/se5/WordCensor/MainWindow.axaml b/se5/WordCensor/MainWindow.axaml
index ca93fa31..aaa245d8 100644
--- a/se5/WordCensor/MainWindow.axaml
+++ b/se5/WordCensor/MainWindow.axaml
@@ -90,6 +90,7 @@
+
+
+
+
+
+
- _blocks;
private readonly WordCensorEngine _engine;
private readonly ObservableCollection _proposals = new();
@@ -30,21 +28,25 @@ public partial class MainWindow : Window
private RadioButton _replacementRadio = null!;
private RadioButton _alternativeRadio = null!;
private TextBox _replacementBox = null!;
+ private Control _selectionControls = null!;
+ private Control _changesBorder = null!;
+ private Control _configurePanel = null!;
public MainWindow() : this(new PluginRequest()) { }
public MainWindow(PluginRequest request)
{
_request = request;
+ _isConfigure = request.RequestType == PluginRequestType.Configure;
InitializeComponent();
_engine = new WordCensorEngine();
_blocks = SubRipParser.Parse(request.Subtitle.SubRip);
- var (mode, replacement, colorRed) = LoadSettings(request.Settings);
- SetMode(mode);
- _replacementBox.Text = replacement;
- _colorRedCheck.IsChecked = colorRed;
+ var saved = CensorSettings.Load(request.Settings);
+ SetMode(saved.Mode);
+ _replacementBox.Text = saved.Replacement;
+ _colorRedCheck.IsChecked = saved.ColorRed;
_grawlixRadio.IsCheckedChanged += (_, _) => OnModeOrOptionChanged();
_replacementRadio.IsCheckedChanged += (_, _) => OnModeOrOptionChanged();
@@ -65,7 +67,18 @@ public MainWindow(PluginRequest request)
var scope = request.SelectedIndices.Count > 0
? $"the {request.SelectedIndices.Count} selected line(s)"
: "all lines";
- _subtitleLabel.Text = $"Replace offensive words in {scope}. Pick a style below.";
+ _subtitleLabel.Text = _isConfigure
+ ? "Settings for Batch convert. Pick a style below."
+ : $"Replace offensive words in {scope}. Pick a style below.";
+
+ if (_isConfigure)
+ {
+ // Opened from Batch convert's Configure button: no subtitle, settings only.
+ _selectionControls.IsVisible = false;
+ _changesBorder.IsVisible = false;
+ _configurePanel.IsVisible = true;
+ _applyButton.Content = "OK";
+ }
UpdateUiForProposals();
}
@@ -83,6 +96,9 @@ private void InitializeComponent()
_replacementRadio = this.FindControl("ReplacementModeRadio")!;
_alternativeRadio = this.FindControl("AlternativeModeRadio")!;
_replacementBox = this.FindControl("ReplacementBox")!;
+ _selectionControls = this.FindControl("SelectionControls")!;
+ _changesBorder = this.FindControl("ChangesBorder")!;
+ _configurePanel = this.FindControl("ConfigurePanel")!;
}
protected override void OnOpened(EventArgs e)
@@ -94,7 +110,7 @@ protected override void OnOpened(EventArgs e)
private CensorOptions BuildOptions() => new()
{
Mode = CurrentMode(),
- Replacement = string.IsNullOrEmpty(_replacementBox.Text) ? DefaultReplacement : _replacementBox.Text,
+ Replacement = string.IsNullOrEmpty(_replacementBox.Text) ? CensorSettings.DefaultReplacement : _replacementBox.Text,
ColorRed = _colorRedCheck.IsChecked == true,
};
@@ -158,6 +174,14 @@ private void OnModeOrOptionChanged()
private void UpdateUiForProposals()
{
+ if (_isConfigure)
+ {
+ _noChangesLabel.IsVisible = false;
+ _applyButton.IsEnabled = true;
+ _summaryLabel.Text = "Used for every file in Batch convert";
+ return;
+ }
+
if (_proposals.Count == 0)
{
_noChangesLabel.IsVisible = true;
@@ -213,6 +237,18 @@ private void OnCancel(object? sender, RoutedEventArgs e)
private void OnApply(object? sender, RoutedEventArgs e)
{
+ if (_isConfigure)
+ {
+ App.Response = new PluginResponse
+ {
+ Status = PluginStatus.Ok,
+ Settings = CensorSettings.Build(BuildOptions()),
+ SettingsVersion = CensorSettings.SettingsVersion,
+ };
+ Close();
+ return;
+ }
+
try
{
var changesByLine = _proposals
@@ -244,7 +280,7 @@ private void OnApply(object? sender, RoutedEventArgs e)
Format = "SubRip",
Native = SubRipParser.Serialize(_blocks),
},
- Settings = BuildSettings(BuildOptions()),
+ Settings = CensorSettings.Build(BuildOptions()),
};
}
catch (Exception ex)
@@ -254,51 +290,4 @@ private void OnApply(object? sender, RoutedEventArgs e)
Close();
}
-
- private static (CensorMode mode, string replacement, bool colorRed) LoadSettings(JsonElement? settings)
- {
- var mode = CensorMode.Grawlix;
- var replacement = DefaultReplacement;
- var colorRed = false;
-
- if (settings is null || settings.Value.ValueKind != JsonValueKind.Object)
- {
- return (mode, replacement, colorRed);
- }
-
- var root = settings.Value;
-
- if (root.TryGetProperty("mode", out var modeProp) && modeProp.ValueKind == JsonValueKind.String &&
- Enum.TryParse(modeProp.GetString(), ignoreCase: true, out var parsed))
- {
- mode = parsed;
- }
- if (root.TryGetProperty("replacement", out var rep) && rep.ValueKind == JsonValueKind.String)
- {
- var s = rep.GetString();
- if (!string.IsNullOrEmpty(s))
- {
- replacement = s;
- }
- }
- if (root.TryGetProperty("colorRed", out var cr) && cr.ValueKind == JsonValueKind.True)
- {
- colorRed = true;
- }
-
- return (mode, replacement, colorRed);
- }
-
- private static JsonElement BuildSettings(CensorOptions options)
- {
- var dto = new
- {
- mode = options.Mode.ToString(),
- replacement = options.Replacement,
- colorRed = options.ColorRed,
- };
- var json = JsonSerializer.Serialize(dto);
- using var doc = JsonDocument.Parse(json);
- return doc.RootElement.Clone();
- }
}
diff --git a/se5/WordCensor/Program.cs b/se5/WordCensor/Program.cs
index afdf0856..66d4ed48 100644
--- a/se5/WordCensor/Program.cs
+++ b/se5/WordCensor/Program.cs
@@ -6,5 +6,5 @@ namespace SubtitleEdit.Plugins.WordCensor;
public static class Program
{
[STAThread]
- public static int Main(string[] args) => PluginBootstrap.Run(args);
+ public static int Main(string[] args) => PluginBootstrap.Run(args, BatchMode.BuildBatchResponse);
}
diff --git a/se5/WordCensor/README.md b/se5/WordCensor/README.md
index 09bfde29..5c23a7f9 100644
--- a/se5/WordCensor/README.md
+++ b/se5/WordCensor/README.md
@@ -21,6 +21,14 @@ A preview window similar to *American to British*:
*Cancel*, and an accent-styled *Apply* (disabled when nothing is checked).
- The "color red" preference is persisted via the plugin Settings round-trip.
+## Batch convert
+
+Word censor 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 style (grawlix, replacement text or random alternative word) and the red highlight.
+- During the batch every offensive word in every line is censored without showing the window, like clicking *Apply* with all lines checked. Files without offensive words are left as they are.
+- Grawlix characters and alternative words are still picked at random, but seeded from the file's text, so converting the same file with the same settings gives the same result.
+
## Build
See `.github/workflows/word-censor.yml`.
diff --git a/se5/WordCensor/plugin.json b/se5/WordCensor/plugin.json
index 3b0a7136..9567585f 100644
--- a/se5/WordCensor/plugin.json
+++ b/se5/WordCensor/plugin.json
@@ -7,6 +7,7 @@
"url": "https://github.com/SubtitleEdit/plugins/tree/main/se5/WordCensor",
"menu": "Tools",
"minSeVersion": "5.0.0",
+ "batch": true,
"runtime": "dotnet",
"entry": "WordCensor.dll"
}