Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/word-censor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions se5/WordCensor.Tests/BatchModeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using SubtitleEdit.Plugins.Shared;
using SubtitleEdit.Plugins.WordCensor;
using System.Text.Json;

namespace WordCensor.Tests;

/// <summary>Subtitle Edit Batch convert: "batch" censors every line headless with the saved settings.</summary>
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<string> 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 <font color=\"#ff0000\">[beep]</font>?" }, 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);
}
}
27 changes: 27 additions & 0 deletions se5/WordCensor.Tests/WordCensor.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<PropertyGroup>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\WordCensor\WordCensor.csproj" />
</ItemGroup>

</Project>
62 changes: 62 additions & 0 deletions se5/WordCensor/BatchMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using SubtitleEdit.Plugins.Shared;
using System;

namespace SubtitleEdit.Plugins.WordCensor;

/// <summary>
/// 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.
/// </summary>
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),
},
};
}

/// <summary>FNV-1a over the text (string.GetHashCode is randomized per process).</summary>
private static int StableSeed(string? text)
{
unchecked
{
var hash = 2166136261u;
foreach (var c in text ?? string.Empty)
{
hash = (hash ^ c) * 16777619u;
}
return (int)hash;
}
}
}
56 changes: 56 additions & 0 deletions se5/WordCensor/CensorSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System;
using System.Text.Json;

namespace SubtitleEdit.Plugins.WordCensor;

/// <summary>The saved plugin settings (mode, replacement text, red highlight) shared by the window and Batch convert.</summary>
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<CensorMode>(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();
}
}
22 changes: 21 additions & 1 deletion se5/WordCensor/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@

<!-- Selection controls -->
<Grid DockPanel.Dock="Top"
x:Name="SelectionControls"
ColumnDefinitions="Auto,Auto,*"
Margin="0,12,0,4">
<Button Grid.Column="0"
Expand All @@ -103,6 +104,24 @@
Margin="8,0,0,0" />
</Grid>

<!-- Configure (Batch convert) -->
<StackPanel DockPanel.Dock="Top"
x:Name="ConfigurePanel"
IsVisible="False"
HorizontalAlignment="Center"
Margin="0,40,0,0"
Spacing="8"
MaxWidth="520">
<TextBlock Text="Batch convert settings"
FontSize="16"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
<TextBlock Opacity="0.7"
TextWrapping="Wrap"
TextAlignment="Center"
Text="Pick a style above and click OK. Batch convert then censors every offensive word in each file without showing this window." />
</StackPanel>

<TextBlock DockPanel.Dock="Top"
x:Name="NoChangesLabel"
FontSize="14"
Expand All @@ -112,7 +131,8 @@
Text="No offensive words found in the selected lines."
IsVisible="False" />

<Border BorderBrush="#22808080"
<Border x:Name="ChangesBorder"
BorderBrush="#22808080"
BorderThickness="1"
CornerRadius="4"
Padding="0"
Expand Down
Loading
Loading