From 3207f13c79317961b6efa9878f091636f7022fde Mon Sep 17 00:00:00 2001 From: Ivandro Jao Date: Sat, 25 Jul 2026 16:33:12 +0100 Subject: [PATCH] Add grammar check via LanguageTool New Tools window that checks the subtitle against a LanguageTool server - the public API or a self-hosted one - and applies the fixes you tick. Formatting tags, ASSA override blocks, music symbols and the line break inside a subtitle are sent as markup, so LanguageTool neither checks nor counts them, while the offsets it answers with still point into the original line - a fix lands exactly where it belongs and the tags stay untouched. Issues spanning a tag or a line break are dropped rather than applied. Lines whose sentence continues into the next subtitle are checked as one sentence, so errors spread over two lines are found. The client and the offset mapping live in libuilogic so the command line converter can use them later; the window reuses the AI review category vocabulary, colours and diff highlighting. Co-Authored-By: Claude Opus 5 (1M context) --- docs/features/grammar-check.md | 52 ++ docs/index.md | 1 + .../Grammar/LanguageToolAnnotatedText.cs | 210 +++++++ src/libuilogic/Grammar/LanguageToolClient.cs | 286 +++++++++ src/libuilogic/Grammar/LanguageToolFix.cs | 52 ++ .../Grammar/LanguageToolLanguage.cs | 25 + src/libuilogic/Grammar/LanguageToolMatch.cs | 33 ++ src/ui/DependencyInjectionExtensions.cs | 2 + src/ui/Features/Main/Layout/InitMenu.cs | 5 + .../Features/Main/Layout/InitNativeMacMenu.cs | 1 + src/ui/Features/Main/MainViewModel.cs | 25 + .../GrammarCheckSettingsViewModel.cs | 67 +++ .../GrammarCheckSettingsWindow.cs | 77 +++ .../GrammarCheckSuggestionItem.cs | 138 +++++ .../GrammarCheck/GrammarCheckViewModel.cs | 560 ++++++++++++++++++ .../Tools/GrammarCheck/GrammarCheckWindow.cs | 427 +++++++++++++ .../Config/Language/Main/LanguageMainMenu.cs | 2 + .../Language/Tools/LanguageGrammarCheck.cs | 73 +++ .../Config/Language/Tools/LanguageTools.cs | 1 + src/ui/Logic/Config/SeGrammarCheck.cs | 34 ++ src/ui/Logic/Config/SeTools.cs | 1 + .../GrammarCheckViewModelTests.cs | 395 ++++++++++++ .../LanguageToolAnnotatedTextTests.cs | 171 ++++++ .../GrammarCheck/LanguageToolClientTests.cs | 116 ++++ .../GrammarCheck/LanguageToolFixTests.cs | 83 +++ 25 files changed, 2837 insertions(+) create mode 100644 docs/features/grammar-check.md create mode 100644 src/libuilogic/Grammar/LanguageToolAnnotatedText.cs create mode 100644 src/libuilogic/Grammar/LanguageToolClient.cs create mode 100644 src/libuilogic/Grammar/LanguageToolFix.cs create mode 100644 src/libuilogic/Grammar/LanguageToolLanguage.cs create mode 100644 src/libuilogic/Grammar/LanguageToolMatch.cs create mode 100644 src/ui/Features/Tools/GrammarCheck/GrammarCheckSettingsViewModel.cs create mode 100644 src/ui/Features/Tools/GrammarCheck/GrammarCheckSettingsWindow.cs create mode 100644 src/ui/Features/Tools/GrammarCheck/GrammarCheckSuggestionItem.cs create mode 100644 src/ui/Features/Tools/GrammarCheck/GrammarCheckViewModel.cs create mode 100644 src/ui/Features/Tools/GrammarCheck/GrammarCheckWindow.cs create mode 100644 src/ui/Logic/Config/Language/Tools/LanguageGrammarCheck.cs create mode 100644 src/ui/Logic/Config/SeGrammarCheck.cs create mode 100644 tests/UI/Features/Tools/GrammarCheck/GrammarCheckViewModelTests.cs create mode 100644 tests/UI/Features/Tools/GrammarCheck/LanguageToolAnnotatedTextTests.cs create mode 100644 tests/UI/Features/Tools/GrammarCheck/LanguageToolClientTests.cs create mode 100644 tests/UI/Features/Tools/GrammarCheck/LanguageToolFixTests.cs diff --git a/docs/features/grammar-check.md b/docs/features/grammar-check.md new file mode 100644 index 00000000000..85a54c4f461 --- /dev/null +++ b/docs/features/grammar-check.md @@ -0,0 +1,52 @@ +# Grammar Check (LanguageTool) + +Check spelling, grammar, punctuation, casing and style with a [LanguageTool](https://languagetool.org) server - the public API, or your own installation (for example the [docker image](https://github.com/Erikvl87/docker-languagetool)). Nothing is changed until you apply the fixes you agree with. + +- **Menu:** Tools → Grammar check (LanguageTool)... + +Unlike [AI Review](ai-review.md), LanguageTool is rule based: it points at an exact span of text and offers concrete replacements, so it never rewrites a line and never invents changes. It is also considerably faster, and with a self-hosted server the subtitle text never leaves your machine. + +## Server + +Enter the base address of the server in the **Server** box - `https://api.languagetool.org` for the public API, or something like `http://localhost:8010` for a local docker container. The address may include the `/v2` or `/v2/check` suffix; Subtitle Edit strips it. + +The circular arrow button next to the box tests the connection and reloads the language list, which also happens automatically when the window opens. + +> The public API is rate limited and rejects large amounts of text from one address. For a whole subtitle, a self-hosted server is the better choice. + +## Language + +The language list comes from the server. It defaults to the auto-detected language of the subtitle, and falls back to the server's own detection if you leave it on **Auto**. For languages with variants (English, German, Portuguese...) pick the right one - the variants disagree about spelling and punctuation. + +**Picky** turns on the stricter rules LanguageTool leaves off by default: redundancy, wordiness and typography suggestions. Useful for a final polish, noisy for a first pass. + +## Checking + +Press **Check**. The subtitle is sent in batches and issues appear in the grid while the check runs - press **Stop** to keep what has been found so far. + +Each row shows: + +- **Apply** — checkbox deciding whether the fix is applied +- **Line number** and a **category** tag (spelling, grammar, punctuation, casing, style) +- **Issue** — LanguageTool's short description of the rule that fired +- **Before / After** — with the changed words highlighted + +Selecting a row shows the full explanation below the grid. When LanguageTool offers more than one replacement, a drop-down appears next to the explanation - pick the one you want and the After column follows. + +Filter the grid with the category chips. Press **Apply N fixes** to apply the checked rows; several fixes on the same line are applied together, and the whole run is a single undo step (Ctrl+Z reverts everything). + +Spelling, grammar, punctuation and casing fixes are checked by default. Style suggestions are a matter of taste, so they start unchecked. Rules that only point at a problem without offering a replacement cannot be checked at all - correct those by hand. + +## Formatting tags and line breaks + +Italic and font tags, ASSA override blocks such as `{\an8}` and music symbols are sent as markup: LanguageTool ignores them instead of reading them as words, but the positions it reports still refer to the original line, so a fix lands exactly where it belongs and the tags stay untouched. On the rare occasion an issue spans a tag or a line break, it is dropped rather than applied - fixing it would break the formatting. + +A sentence continuing into the next subtitle is checked as one sentence, the way it reads on screen, so agreement errors spread over two lines are found. Fixes are still applied per line, so timing and reading speed are unaffected. + +## Settings + +The **Settings** button holds the options that are rarely changed: + +- **User name** and **API key** — only needed for a LanguageTool premium account, or a server that requires credentials +- **Disabled rules** — comma separated rule ids to ignore, e.g. `WHITESPACE_RULE,UPPERCASE_SENTENCE_START`. The rule id of a selected issue is shown in the explanation line +- **Lines per request** — how many subtitle lines go into one request (25 by default) diff --git a/docs/index.md b/docs/index.md index 0076eff1d1c..ec3001bfccd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,6 +21,7 @@ Subtitle Edit is a free, open-source editor for video subtitles. This is the doc ### Tools - [AI Review](features/ai-review.md) — Proofread with a local LLM (llama.cpp, Ollama, or any OpenAI-compatible endpoint) +- [Grammar Check](features/grammar-check.md) — Spelling, grammar, punctuation and style via a LanguageTool server (public API or self-hosted) - [Fix Common Errors](features/fix-common-errors.md) — Automatic error detection and fixing - [Check and Fix Netflix Errors](features/netflix-errors.md) — Netflix quality checks, proposed fixes, and CSV reports - [Batch Convert](features/batch-convert.md) — Convert multiple subtitle files diff --git a/src/libuilogic/Grammar/LanguageToolAnnotatedText.cs b/src/libuilogic/Grammar/LanguageToolAnnotatedText.cs new file mode 100644 index 00000000000..dd5e9b5f7b4 --- /dev/null +++ b/src/libuilogic/Grammar/LanguageToolAnnotatedText.cs @@ -0,0 +1,210 @@ +using Nikse.SubtitleEdit.Core.Common; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Nikse.SubtitleEdit.UiLogic.Grammar; + +/// +/// Builds the annotated document that LanguageTool's /v2/check "data" parameter takes, and maps the +/// offsets it answers with back onto the subtitle lines that went in. +/// +/// Formatting tags, music symbols and the line break inside a subtitle are sent as "markup" so +/// LanguageTool neither spell checks them nor reads them as words, while the offsets it returns stay +/// relative to the complete text - markup included - so a replacement can be applied straight to the +/// original line instead of rebuilding it from a cleaned copy. +/// +/// Consecutive lines go into one document, joined with a space where the sentence continues into the +/// next line and with a blank line where it does not, so grammar is checked the way the text reads on +/// screen rather than one subtitle at a time. +/// +public class LanguageToolAnnotatedText +{ + // Same shapes the AI review protocol treats as tags, plus the line break and music symbols. + private static readonly Regex MarkupRegex = new(@"<[^>]*>|\{\\[^}]*\}|\r\n|\n|[♪♫♬♩]", RegexOptions.Compiled); + + private readonly List _lineSpans; + private readonly List _markupSpans; + + /// The value for the "data" parameter. + public string Json { get; } + + /// The complete text - markup included - that the returned offsets refer to. + public string Text { get; } + + public bool IsEmpty => Text.Trim().Length == 0; + + private LanguageToolAnnotatedText(string json, string text, List lineSpans, List markupSpans) + { + Json = json; + Text = text; + _lineSpans = lineSpans; + _markupSpans = markupSpans; + } + + public static LanguageToolAnnotatedText Build(IReadOnlyList lines) + { + var text = new StringBuilder(); + var lineSpans = new List(lines.Count); + var markupSpans = new List(); + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteStartArray("annotation"); + + for (var i = 0; i < lines.Count; i++) + { + if (i > 0) + { + // A separator of our own, so it is markup: interpreted as a space while the + // sentence runs on, as a paragraph break once it has ended. + const string separator = "\n"; + WriteMarkup(writer, separator, EndsSentence(lines[i - 1]) ? "\n\n" : " "); + markupSpans.Add(new TextSpan(text.Length, separator.Length)); + text.Append(separator); + } + + var start = text.Length; + AppendLine(writer, text, markupSpans, lines[i] ?? string.Empty); + lineSpans.Add(new TextSpan(start, text.Length - start)); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + return new LanguageToolAnnotatedText(Encoding.UTF8.GetString(stream.ToArray()), text.ToString(), lineSpans, markupSpans); + } + + /// + /// Maps a match onto the line it belongs to. False when the match spans more than one line or + /// touches markup - those cannot be applied without mangling a tag or a line break. + /// + public bool TryMapToLine(int offset, int length, out int lineIndex, out int lineOffset) + { + lineIndex = -1; + lineOffset = -1; + if (length <= 0 || offset < 0 || offset + length > Text.Length) + { + return false; + } + + for (var i = 0; i < _lineSpans.Count; i++) + { + var span = _lineSpans[i]; + if (offset < span.Start || offset + length > span.Start + span.Length) + { + continue; + } + + if (OverlapsMarkup(offset, length)) + { + return false; + } + + lineIndex = i; + lineOffset = offset - span.Start; + return true; + } + + return false; + } + + private bool OverlapsMarkup(int offset, int length) + { + foreach (var span in _markupSpans) + { + if (offset < span.Start + span.Length && span.Start < offset + length) + { + return true; + } + } + + return false; + } + + /// True when the line finishes a sentence, i.e. the next line starts a new one. + public static bool EndsSentence(string text) + { + var s = HtmlUtil.RemoveHtmlTags(text ?? string.Empty, true).TrimEnd(); + if (s.Length == 0) + { + return true; + } + + // skip closing quotes/brackets after the sentence-final mark + var i = s.Length - 1; + while (i >= 0 && "\"'’”»)]".IndexOf(s[i]) >= 0) + { + i--; + } + + if (i < 0) + { + return true; + } + + return ".!?…".IndexOf(s[i]) >= 0; + } + + private static void AppendLine(Utf8JsonWriter writer, StringBuilder text, List markupSpans, string line) + { + var index = 0; + foreach (Match match in MarkupRegex.Matches(line)) + { + if (match.Index > index) + { + var plain = line.Substring(index, match.Index - index); + WriteText(writer, plain); + text.Append(plain); + } + + // A line break inside a subtitle is a space to the reader, tags and music symbols are nothing. + var isNewLine = match.Value == "\n" || match.Value == "\r\n"; + WriteMarkup(writer, match.Value, isNewLine ? " " : null); + markupSpans.Add(new TextSpan(text.Length, match.Length)); + text.Append(match.Value); + index = match.Index + match.Length; + } + + if (index < line.Length) + { + var rest = line.Substring(index); + WriteText(writer, rest); + text.Append(rest); + } + } + + private static void WriteText(Utf8JsonWriter writer, string value) + { + writer.WriteStartObject(); + writer.WriteString("text", value); + writer.WriteEndObject(); + } + + private static void WriteMarkup(Utf8JsonWriter writer, string value, string? interpretAs) + { + writer.WriteStartObject(); + writer.WriteString("markup", value); + if (interpretAs != null) + { + writer.WriteString("interpretAs", interpretAs); + } + + writer.WriteEndObject(); + } + + private readonly struct TextSpan + { + public TextSpan(int start, int length) + { + Start = start; + Length = length; + } + + public int Start { get; } + public int Length { get; } + } +} diff --git a/src/libuilogic/Grammar/LanguageToolClient.cs b/src/libuilogic/Grammar/LanguageToolClient.cs new file mode 100644 index 00000000000..211bef8e812 --- /dev/null +++ b/src/libuilogic/Grammar/LanguageToolClient.cs @@ -0,0 +1,286 @@ +using Nikse.SubtitleEdit.Core.Common; +using System.Net.Http; +using System.Text.Json; + +namespace Nikse.SubtitleEdit.UiLogic.Grammar; + +/// Everything but the text that goes into a /v2/check call. +public class LanguageToolOptions +{ + /// A long code like "en-US", or "auto" to let the server detect the language. + public string Language { get; set; } = LanguageToolLanguage.AutoCode; + + /// Variants to prefer when detecting, e.g. "en-US,de-DE" - only used with "auto". + public string PreferredVariants { get; set; } = string.Empty; + + /// Turns on the rules LanguageTool considers too opinionated for the default level. + public bool Picky { get; set; } + + /// Comma separated rule ids to switch off, e.g. "WHITESPACE_RULE,UPPERCASE_SENTENCE_START". + public string DisabledRules { get; set; } = string.Empty; + + /// Premium/self-hosted account - both this and are needed, or neither. + public string Username { get; set; } = string.Empty; + + public string ApiKey { get; set; } = string.Empty; +} + +/// +/// Talks to a LanguageTool HTTP server - either languagetool.org or a self-hosted one +/// (e.g. the erikvl87/languagetool docker image). See https://languagetool.org/http-api/ +/// +public class LanguageToolClient : IDisposable +{ + public const string DefaultServerUrl = "https://api.languagetool.org"; + + // LanguageTool happily returns 40+ candidates for an unknown word; only the best few are useful + // in a drop-down, and the rest just make the list hard to read. + private const int MaxReplacements = 8; + + private readonly HttpClient _httpClient; + + public LanguageToolClient() + { + _httpClient = HttpClientFactoryWithProxy.CreateHttpClientWithProxy(); + _httpClient.Timeout = TimeSpan.FromMinutes(2); + } + + /// + /// Builds an endpoint url from what the user typed. Takes "host", "https://host", "https://host/", + /// "https://host/v2" and "https://host/v2/check" alike - people paste all of them. + /// + public static string GetEndpointUrl(string? serverUrl, string path) + { + var url = (serverUrl ?? string.Empty).Trim(); + if (url.Length == 0) + { + url = DefaultServerUrl; + } + + if (!url.Contains("://")) + { + url = "https://" + url; + } + + url = url.TrimEnd('/'); + foreach (var suffix in new[] { "/v2/check", "/v2/languages", "/v2" }) + { + if (url.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + url = url.Substring(0, url.Length - suffix.Length); + break; + } + } + + return url.TrimEnd('/') + path; + } + + /// The languages the server has rules for - also the cheapest way to check it answers at all. + public async Task> GetLanguagesAsync(string? serverUrl, CancellationToken cancellationToken) + { + var url = GetEndpointUrl(serverUrl, "/v2/languages"); + using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + SeLogger.Error("LanguageTool: /v2/languages failed: " + body); + throw new HttpRequestException(ShortError(body, response.StatusCode.ToString())); + } + + return ParseLanguages(body); + } + + public async Task> CheckAsync(string? serverUrl, string dataJson, LanguageToolOptions options, CancellationToken cancellationToken) + { + var url = GetEndpointUrl(serverUrl, "/v2/check"); + var language = string.IsNullOrWhiteSpace(options.Language) ? LanguageToolLanguage.AutoCode : options.Language.Trim(); + var form = new List> + { + new("data", dataJson), + new("language", language), + }; + + if (language == LanguageToolLanguage.AutoCode && !string.IsNullOrWhiteSpace(options.PreferredVariants)) + { + // Rejected by the server together with an explicit language, so only sent with "auto". + form.Add(new KeyValuePair("preferredVariants", options.PreferredVariants.Trim())); + } + + if (options.Picky) + { + form.Add(new KeyValuePair("level", "picky")); + } + + if (!string.IsNullOrWhiteSpace(options.DisabledRules)) + { + form.Add(new KeyValuePair("disabledRules", options.DisabledRules.Trim())); + } + + if (!string.IsNullOrWhiteSpace(options.Username) && !string.IsNullOrWhiteSpace(options.ApiKey)) + { + form.Add(new KeyValuePair("username", options.Username.Trim())); + form.Add(new KeyValuePair("apiKey", options.ApiKey.Trim())); + } + + using var content = new FormUrlEncodedContent(form); + using var response = await _httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + SeLogger.Error("LanguageTool: /v2/check failed: " + body); + throw new HttpRequestException(ShortError(body, response.StatusCode.ToString())); + } + + return ParseMatches(body); + } + + public static List ParseLanguages(string json) + { + var languages = new List(); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) + { + return languages; + } + + foreach (var element in doc.RootElement.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Object) + { + continue; + } + + var name = GetString(element, "name"); + var code = GetString(element, "code"); + var longCode = GetString(element, "longCode"); + if (longCode.Length == 0) + { + longCode = code; + } + + if (longCode.Length == 0) + { + continue; + } + + languages.Add(new LanguageToolLanguage + { + Name = name.Length == 0 ? longCode : name, + Code = code, + LongCode = longCode, + }); + } + + return languages; + } + + public static List ParseMatches(string json) + { + var matches = new List(); + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("matches", out var array) || array.ValueKind != JsonValueKind.Array) + { + return matches; + } + + foreach (var element in array.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Object || + !element.TryGetProperty("offset", out var offsetElement) || + offsetElement.ValueKind != JsonValueKind.Number || + !offsetElement.TryGetInt32(out var offset) || + !element.TryGetProperty("length", out var lengthElement) || + lengthElement.ValueKind != JsonValueKind.Number || + !lengthElement.TryGetInt32(out var length)) + { + continue; + } + + var replacements = new List(); + if (element.TryGetProperty("replacements", out var replacementArray) && replacementArray.ValueKind == JsonValueKind.Array) + { + foreach (var replacement in replacementArray.EnumerateArray()) + { + if (replacement.ValueKind == JsonValueKind.Object && + replacement.TryGetProperty("value", out var value) && + value.ValueKind == JsonValueKind.String) + { + var text = value.GetString(); + if (!string.IsNullOrEmpty(text) && !replacements.Contains(text)) + { + replacements.Add(text!); + } + } + + if (replacements.Count >= MaxReplacements) + { + break; + } + } + } + + var ruleId = string.Empty; + var ruleDescription = string.Empty; + var issueType = string.Empty; + var categoryId = string.Empty; + var categoryName = string.Empty; + if (element.TryGetProperty("rule", out var rule) && rule.ValueKind == JsonValueKind.Object) + { + ruleId = GetString(rule, "id"); + ruleDescription = GetString(rule, "description"); + issueType = GetString(rule, "issueType"); + if (rule.TryGetProperty("category", out var category) && category.ValueKind == JsonValueKind.Object) + { + categoryId = GetString(category, "id"); + categoryName = GetString(category, "name"); + } + } + + matches.Add(new LanguageToolMatch + { + Offset = offset, + Length = length, + Message = GetString(element, "message"), + ShortMessage = GetString(element, "shortMessage"), + RuleId = ruleId, + RuleDescription = ruleDescription, + IssueType = issueType, + CategoryId = categoryId, + CategoryName = categoryName, + Replacements = replacements, + }); + } + + return matches; + } + + private static string GetString(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : string.Empty; + } + + /// LanguageTool answers errors as plain text ("Error: ..."), sometimes with a stack trace. + private static string ShortError(string body, string fallback) + { + var s = (body ?? string.Empty).Trim(); + if (s.Length == 0) + { + return fallback; + } + + var lineBreak = s.IndexOfAny(new[] { '\r', '\n' }); + if (lineBreak > 0) + { + s = s.Substring(0, lineBreak); + } + + return s.Length > 300 ? s.Substring(0, 300) + "..." : s; + } + + public void Dispose() + { + _httpClient.Dispose(); + } +} diff --git a/src/libuilogic/Grammar/LanguageToolFix.cs b/src/libuilogic/Grammar/LanguageToolFix.cs new file mode 100644 index 00000000000..3745be1de12 --- /dev/null +++ b/src/libuilogic/Grammar/LanguageToolFix.cs @@ -0,0 +1,52 @@ +namespace Nikse.SubtitleEdit.UiLogic.Grammar; + +/// A replacement for the text at [, + ). +public readonly struct LanguageToolFixItem +{ + public LanguageToolFixItem(int offset, int length, string replacement) + { + Offset = offset; + Length = length; + Replacement = replacement ?? string.Empty; + } + + public int Offset { get; } + public int Length { get; } + public string Replacement { get; } +} + +public static class LanguageToolFix +{ + /// + /// Applies replacements to one line. Right to left, so the offsets of the fixes still waiting stay + /// valid, and fixes overlapping one already applied are skipped rather than corrupting the line. + /// + public static string Apply(string text, IEnumerable fixes) + { + if (string.IsNullOrEmpty(text)) + { + return text ?? string.Empty; + } + + var ordered = fixes + .Where(f => f.Offset >= 0 && f.Length > 0 && f.Offset + f.Length <= text.Length) + .OrderByDescending(f => f.Offset) + .ThenByDescending(f => f.Length) + .ToList(); + + var result = text; + var appliedStart = text.Length; + foreach (var item in ordered) + { + if (item.Offset + item.Length > appliedStart) + { + continue; + } + + result = result.Remove(item.Offset, item.Length).Insert(item.Offset, item.Replacement); + appliedStart = item.Offset; + } + + return result; + } +} diff --git a/src/libuilogic/Grammar/LanguageToolLanguage.cs b/src/libuilogic/Grammar/LanguageToolLanguage.cs new file mode 100644 index 00000000000..4a6deced4b7 --- /dev/null +++ b/src/libuilogic/Grammar/LanguageToolLanguage.cs @@ -0,0 +1,25 @@ +namespace Nikse.SubtitleEdit.UiLogic.Grammar; + +/// +/// A language as listed by LanguageTool's /v2/languages: "English (US)", code "en", long code "en-US". +/// +public class LanguageToolLanguage +{ + /// The value to send as the "language" parameter for automatic detection. + public const string AutoCode = "auto"; + + public string Name { get; init; } = string.Empty; + + /// Two/three letter code without the variant, e.g. "en". + public string Code { get; init; } = string.Empty; + + /// Code including the variant, e.g. "en-US" - this is what gets sent to the server. + public string LongCode { get; init; } = string.Empty; + + public bool IsAuto => LongCode == AutoCode; + + public override string ToString() + { + return Name; + } +} diff --git a/src/libuilogic/Grammar/LanguageToolMatch.cs b/src/libuilogic/Grammar/LanguageToolMatch.cs new file mode 100644 index 00000000000..a46b8513923 --- /dev/null +++ b/src/libuilogic/Grammar/LanguageToolMatch.cs @@ -0,0 +1,33 @@ +namespace Nikse.SubtitleEdit.UiLogic.Grammar; + +/// +/// One issue reported by LanguageTool. and are relative +/// to the complete text that was sent - markup included - see . +/// +public class LanguageToolMatch +{ + public int Offset { get; init; } + public int Length { get; init; } + + /// The full explanation, e.g. "The pronoun 'He' is usually used with a third-person verb". + public string Message { get; init; } = string.Empty; + + /// A few words naming the issue, e.g. "Agreement error". Often empty. + public string ShortMessage { get; init; } = string.Empty; + + /// Rule id, e.g. "HE_VERB_AGR" - the value to put in "disabled rules". + public string RuleId { get; init; } = string.Empty; + + public string RuleDescription { get; init; } = string.Empty; + + /// LanguageTool issue type, e.g. "misspelling", "grammar", "typographical", "style". + public string IssueType { get; init; } = string.Empty; + + /// Rule category id, e.g. "TYPOS", "GRAMMAR", "PUNCTUATION", "CASING". + public string CategoryId { get; init; } = string.Empty; + + public string CategoryName { get; init; } = string.Empty; + + /// Suggested replacements for the flagged text, best first. Can be empty. + public IReadOnlyList Replacements { get; init; } = new List(); +} diff --git a/src/ui/DependencyInjectionExtensions.cs b/src/ui/DependencyInjectionExtensions.cs index 0d814c241b1..b056d5a5753 100644 --- a/src/ui/DependencyInjectionExtensions.cs +++ b/src/ui/DependencyInjectionExtensions.cs @@ -394,6 +394,8 @@ public static void AddSubtitleEditServices(this IServiceCollection collection) collection.AddTransient(); collection.AddTransient(); collection.AddTransient(); + collection.AddTransient(); + collection.AddTransient(); collection.AddTransient(); collection.AddTransient(); collection.AddTransient(); diff --git a/src/ui/Features/Main/Layout/InitMenu.cs b/src/ui/Features/Main/Layout/InitMenu.cs index aa4f4ce72c8..ff8eaf78f92 100644 --- a/src/ui/Features/Main/Layout/InitMenu.cs +++ b/src/ui/Features/Main/Layout/InitMenu.cs @@ -472,6 +472,11 @@ public static void Make(MainViewModel vm) Command = vm.ShowToolsAiReviewCommand, }, new MenuItem + { + Header = l.GrammarCheck, + Command = vm.ShowToolsGrammarCheckCommand, + }, + new MenuItem { Header = l.MakeEmptyTranslationFromCurrentSubtitle, Command = vm.ToolsMakeEmptyTranslationFromCurrentSubtitleCommand, diff --git a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs index cf27ffacbc1..a11c6074336 100644 --- a/src/ui/Features/Main/Layout/InitNativeMacMenu.cs +++ b/src/ui/Features/Main/Layout/InitNativeMacMenu.cs @@ -287,6 +287,7 @@ public static void MakeStructure(NativeMenu root, Window window) Item(Clean(l.FixCommonErrors), v => v.ShowToolsFixCommonErrorsCommand), Item(Clean(l.CheckAndFixNetflixErrors), v => v.ShowToolsFixNetflixErrorsCommand), Item(Clean(l.AiReview), v => v.ShowToolsAiReviewCommand), + Item(Clean(l.GrammarCheck), v => v.ShowToolsGrammarCheckCommand), Item(Clean(l.MakeEmptyTranslationFromCurrentSubtitle), v => v.ToolsMakeEmptyTranslationFromCurrentSubtitleCommand), Item(Clean(l.MergeLinesWithSameText), v => v.ShowToolsMergeLinesWithSameTextCommand), Item(Clean(l.MergeLinesWithSameTimeCodes), v => v.ShowToolsMergeLinesWithSameTimeCodesCommand), diff --git a/src/ui/Features/Main/MainViewModel.cs b/src/ui/Features/Main/MainViewModel.cs index 453536a40fb..8ba23e2d9ba 100644 --- a/src/ui/Features/Main/MainViewModel.cs +++ b/src/ui/Features/Main/MainViewModel.cs @@ -122,6 +122,7 @@ using Nikse.SubtitleEdit.Features.Tools.ChangeFormatting; using Nikse.SubtitleEdit.Features.Tools.ConvertActors; using Nikse.SubtitleEdit.Features.Tools.AiReview; +using Nikse.SubtitleEdit.Features.Tools.GrammarCheck; using Nikse.SubtitleEdit.Features.Tools.FixCommonErrors; using Nikse.SubtitleEdit.Features.Tools.FixNetflixErrors; using Nikse.SubtitleEdit.Features.Tools.JoinSubtitles; @@ -5468,6 +5469,30 @@ private async Task ShowToolsAiReview() } } + [RelayCommand] + private async Task ShowToolsGrammarCheck() + { + if (Window == null) + { + return; + } + + if (IsEmpty) + { + ShowSubtitleNotLoadedMessage(); + return; + } + + var idx = SelectedSubtitleIndex ?? 0; + var viewModel = await ShowDialogAsync(vm => { vm.Initialize(GetUpdateSubtitle(), SelectedSubtitleFormat); }); + + if (viewModel is { OkPressed: true, FixedCount: > 0 }) + { + ApplyFixedSubtitle(viewModel.FixedSubtitle, idx, SelectedSubtitleFormat); + ShowStatus(string.Format(Se.Language.Main.FixedXLines, viewModel.FixedCount)); + } + } + [RelayCommand] private async Task ShowAiAssistant() { diff --git a/src/ui/Features/Tools/GrammarCheck/GrammarCheckSettingsViewModel.cs b/src/ui/Features/Tools/GrammarCheck/GrammarCheckSettingsViewModel.cs new file mode 100644 index 00000000000..3896f6521be --- /dev/null +++ b/src/ui/Features/Tools/GrammarCheck/GrammarCheckSettingsViewModel.cs @@ -0,0 +1,67 @@ +using Avalonia.Controls; +using Avalonia.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Tools.GrammarCheck; + +/// +/// The LanguageTool settings that are not worth a spot in the main toolbar: the credentials a +/// premium (or protected self-hosted) server wants, the rules to ignore and the request size. +/// +public partial class GrammarCheckSettingsViewModel : ObservableObject +{ + [ObservableProperty] private string _username; + [ObservableProperty] private string _apiKey; + [ObservableProperty] private string _disabledRules; + [ObservableProperty] private int _maxLinesPerBatch; + + public Window? Window { get; set; } + public bool OkPressed { get; private set; } + + public GrammarCheckSettingsViewModel() + { + _username = string.Empty; + _apiKey = string.Empty; + _disabledRules = string.Empty; + _maxLinesPerBatch = 25; + } + + public void Initialize() + { + var settings = Se.Settings.Tools.GrammarCheck; + Username = settings.Username; + ApiKey = settings.ApiKey; + DisabledRules = settings.DisabledRules; + MaxLinesPerBatch = settings.MaxLinesPerBatch; + } + + [RelayCommand] + private void Ok() + { + var settings = Se.Settings.Tools.GrammarCheck; + settings.Username = Username.Trim(); + settings.ApiKey = ApiKey.Trim(); + settings.DisabledRules = DisabledRules.Trim(); + settings.MaxLinesPerBatch = MaxLinesPerBatch < 1 ? 1 : MaxLinesPerBatch; + Se.SaveSettings(); + OkPressed = true; + Window?.Close(); + } + + [RelayCommand] + private void Cancel() + { + Window?.Close(); + } + + internal void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + Window?.Close(); + } + } +} diff --git a/src/ui/Features/Tools/GrammarCheck/GrammarCheckSettingsWindow.cs b/src/ui/Features/Tools/GrammarCheck/GrammarCheckSettingsWindow.cs new file mode 100644 index 00000000000..d580fb533c0 --- /dev/null +++ b/src/ui/Features/Tools/GrammarCheck/GrammarCheckSettingsWindow.cs @@ -0,0 +1,77 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; + +namespace Nikse.SubtitleEdit.Features.Tools.GrammarCheck; + +public class GrammarCheckSettingsWindow : Window +{ + public GrammarCheckSettingsWindow(GrammarCheckSettingsViewModel vm) + { + UiUtil.InitializeWindow(this, GetType().Name); + Title = Se.Language.Tools.GrammarCheck.SettingsTitle; + Width = 560; + SizeToContent = SizeToContent.Height; + CanResize = false; + vm.Window = this; + DataContext = vm; + + var l = Se.Language.Tools.GrammarCheck; + + var labelInfo = UiUtil.MakeTextBlock(l.SettingsInfo); + labelInfo.TextWrapping = TextWrapping.Wrap; + labelInfo.Opacity = 0.75; + + var textBoxUsername = UiUtil.MakeTextBox(300, vm, nameof(vm.Username)) + .WithAccessibleName(l.Username); + var textBoxApiKey = UiUtil.MakeTextBox(300, vm, nameof(vm.ApiKey)) + .WithAccessibleName(Se.Language.General.ApiKey); + textBoxApiKey.PasswordChar = '●'; + var textBoxDisabledRules = UiUtil.MakeTextBox(300, vm, nameof(vm.DisabledRules)) + .WithAccessibleName(l.DisabledRules); + ToolTip.SetTip(textBoxDisabledRules, l.DisabledRulesHint); + var numericMaxLines = UiUtil.MakeNumericUpDownInt(1, 500, 25, 100, vm, nameof(vm.MaxLinesPerBatch)) + .WithAccessibleName(l.MaxLinesPerBatch); + + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + RowDefinitions = new RowDefinitions("Auto,Auto,Auto,Auto"), + ColumnSpacing = 10, + RowSpacing = 8, + }; + grid.Add(UiUtil.MakeTextBlock(l.Username), 0, 0); + grid.Add(textBoxUsername, 0, 1); + grid.Add(UiUtil.MakeTextBlock(Se.Language.General.ApiKey), 1, 0); + grid.Add(textBoxApiKey, 1, 1); + grid.Add(UiUtil.MakeTextBlock(l.DisabledRules), 2, 0); + grid.Add(textBoxDisabledRules, 2, 1); + grid.Add(UiUtil.MakeTextBlock(l.MaxLinesPerBatch), 3, 0); + grid.Add(numericMaxLines, 3, 1); + + var panel = new StackPanel + { + Margin = UiUtil.MakeWindowMargin(), + Spacing = 12, + Children = + { + labelInfo, + grid, + UiUtil.MakeButtonBar(UiUtil.MakeButtonOk(vm.OkCommand), UiUtil.MakeButtonCancel(vm.CancelCommand)), + }, + }; + + Content = panel; + + Loaded += delegate + { + textBoxUsername.Focus(); + UiUtil.RestoreWindowPosition(this); + }; + Closing += delegate { UiUtil.SaveWindowPosition(this); }; + KeyDown += (_, e) => vm.OnKeyDown(e); + } +} diff --git a/src/ui/Features/Tools/GrammarCheck/GrammarCheckSuggestionItem.cs b/src/ui/Features/Tools/GrammarCheck/GrammarCheckSuggestionItem.cs new file mode 100644 index 00000000000..06bc65f4dd0 --- /dev/null +++ b/src/ui/Features/Tools/GrammarCheck/GrammarCheckSuggestionItem.cs @@ -0,0 +1,138 @@ +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using Nikse.SubtitleEdit.Features.Tools.AiReview; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.UiLogic.Grammar; +using System.Collections.Generic; + +namespace Nikse.SubtitleEdit.Features.Tools.GrammarCheck; + +/// +/// One LanguageTool match against one subtitle line. Unlike an AI review suggestion this is a span +/// inside the line, not a rewrite of it, so several of these can point at the same line - they are +/// applied together in . +/// +/// The category vocabulary (and its colours) is shared with AI review on purpose: both windows list +/// the same kinds of issue, and two colour schemes for one concept would only confuse. +/// +public partial class GrammarCheckSuggestionItem : ObservableObject +{ + private static readonly Dictionary BackgroundBrushes = new(); + + [ObservableProperty] private bool _isSelected; + [ObservableProperty] private string _replacement = string.Empty; + + public int Number { get; init; } + public int ParagraphIndex { get; init; } + + /// Start of the flagged text inside the line. + public int Offset { get; init; } + + public int Length { get; init; } + public ReviewCategory Category { get; init; } + + /// The line as it is now. + public string Before { get; init; } = string.Empty; + + /// The flagged text itself, e.g. "go" in "He go to school". + public string Fragment { get; init; } = string.Empty; + + public string Message { get; init; } = string.Empty; + public string ShortMessage { get; init; } = string.Empty; + public string RuleId { get; init; } = string.Empty; + public IReadOnlyList Replacements { get; init; } = new List(); + + /// False for rules that only point at a problem without offering a fix. + public bool CanApply => Replacements.Count > 0; + + /// The line with this one replacement applied - what the "After" column shows. + public string After => CanApply + ? LanguageToolFix.Apply(Before, new[] { new LanguageToolFixItem(Offset, Length, Replacement) }) + : Before; + + /// Short label for the grid; LanguageTool leaves the short message out for many rules. + public string IssueDisplay => ShortMessage.Length > 0 ? ShortMessage : Message; + + public string CategoryDisplay => Category switch + { + ReviewCategory.Spelling => Se.Language.Tools.GrammarCheck.CategorySpelling, + ReviewCategory.Grammar => Se.Language.Tools.GrammarCheck.CategoryGrammar, + ReviewCategory.Punctuation => Se.Language.Tools.GrammarCheck.CategoryPunctuation, + ReviewCategory.Casing => Se.Language.Tools.GrammarCheck.CategoryCasing, + _ => Se.Language.Tools.GrammarCheck.CategoryStyle, + }; + + public IBrush CategoryBrush => ReviewSuggestionItem.GetBrushForCategory(Category); + + public IBrush CategoryBackgroundBrush => GetBackgroundBrush(Category); + + public string CategoryIconName => Category switch + { + ReviewCategory.Spelling => "mdi-spellcheck", + ReviewCategory.Grammar => "mdi-text", + ReviewCategory.Punctuation => "mdi-comma", + ReviewCategory.Casing => "mdi-format-letter-case", + _ => "mdi-dots-horizontal", + }; + + partial void OnReplacementChanged(string value) + { + OnPropertyChanged(nameof(After)); + } + + /// + /// Maps a LanguageTool rule onto the shared review categories. The rule category is the better + /// signal (LanguageTool groups its own rules with it); the issue type only fills the gaps. + /// + public static ReviewCategory MapCategory(string categoryId, string issueType) + { + switch ((categoryId ?? string.Empty).ToUpperInvariant()) + { + case "CASING": + return ReviewCategory.Casing; + case "TYPOS": + case "TYPO": + return ReviewCategory.Spelling; + case "PUNCTUATION": + case "TYPOGRAPHY": + return ReviewCategory.Punctuation; + case "GRAMMAR": + case "COMPOUNDING": + case "CONFUSED_WORDS": + case "COLLOCATIONS": + case "SEMANTICS": + return ReviewCategory.Grammar; + } + + switch ((issueType ?? string.Empty).ToLowerInvariant()) + { + case "misspelling": + return ReviewCategory.Spelling; + case "grammar": + case "duplication": + case "inconsistency": + case "agreement": + return ReviewCategory.Grammar; + case "typographical": + case "whitespace": + case "formatting": + case "characters": + return ReviewCategory.Punctuation; + default: + return ReviewCategory.Other; + } + } + + private static IBrush GetBackgroundBrush(ReviewCategory category) + { + if (BackgroundBrushes.TryGetValue(category, out var brush)) + { + return brush; + } + + var color = (ReviewSuggestionItem.GetBrushForCategory(category) as ISolidColorBrush)?.Color ?? Colors.Gray; + brush = new SolidColorBrush(Color.FromArgb(0x20, color.R, color.G, color.B)); + BackgroundBrushes[category] = brush; + return brush; + } +} diff --git a/src/ui/Features/Tools/GrammarCheck/GrammarCheckViewModel.cs b/src/ui/Features/Tools/GrammarCheck/GrammarCheckViewModel.cs new file mode 100644 index 00000000000..cdb938614b3 --- /dev/null +++ b/src/ui/Features/Tools/GrammarCheck/GrammarCheckViewModel.cs @@ -0,0 +1,560 @@ +using Avalonia.Controls; +using Avalonia.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Core.SubtitleFormats; +using Nikse.SubtitleEdit.Features.Shared; +using Nikse.SubtitleEdit.Features.Tools.AiReview; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.UiLogic.Grammar; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Nikse.SubtitleEdit.Features.Tools.GrammarCheck; + +public partial class GrammarCheckViewModel : ObservableObject +{ + [ObservableProperty] private string _serverUrl; + [ObservableProperty] private ObservableCollection _languages; + [ObservableProperty] private LanguageToolLanguage? _selectedLanguage; + [ObservableProperty] private bool _isPicky; + [ObservableProperty] private ObservableCollection _filterChips; + [ObservableProperty] private ObservableCollection _suggestions; + [ObservableProperty] private GrammarCheckSuggestionItem? _selectedSuggestion; + [ObservableProperty] private ObservableCollection _replacementOptions; + [ObservableProperty] private string? _selectedReplacementOption; + [ObservableProperty] private bool _hasReplacementOptions; + [ObservableProperty] private bool _isChecking; + [ObservableProperty] private bool _isNotChecking = true; + [ObservableProperty] private double _progressValue; + [ObservableProperty] private string _statusText; + [ObservableProperty] private string _messageText; + [ObservableProperty] private bool _hasMessage; + [ObservableProperty] private string _summaryText; + [ObservableProperty] private string _applyButtonText; + + public Window? Window { get; set; } + public bool OkPressed { get; private set; } + public Subtitle FixedSubtitle { get; private set; } = new(); + + /// Number of lines actually changed by . + public int FixedCount { get; private set; } + + public int SelectedCount => _allSuggestions.Count(s => s.IsSelected && s.CanApply); + + private readonly IWindowService _windowService; + private readonly List _allSuggestions = new(); + private Subtitle _subtitle = new(); + private string _autoDetectedLanguageCode = "en"; + private CancellationTokenSource _cancellationTokenSource = new(); + + /// Cancels the language lookup when the window closes, so a hung server does not keep it alive. + private readonly CancellationTokenSource _windowCancellationTokenSource = new(); + + private bool _languagesLoaded; + private bool _updatingReplacementOptions; + + public GrammarCheckViewModel(IWindowService windowService) + { + _windowService = windowService; + + var settings = Se.Settings.Tools.GrammarCheck; + ServerUrl = settings.ServerUrl; + IsPicky = settings.Picky; + Languages = new ObservableCollection { MakeAutoLanguage() }; + SelectedLanguage = Languages[0]; + Suggestions = new ObservableCollection(); + ReplacementOptions = new ObservableCollection(); + StatusText = string.Empty; + MessageText = string.Empty; + SummaryText = string.Empty; + + var l = Se.Language.Tools.GrammarCheck; + FilterChips = new ObservableCollection + { + new() { Category = null, Label = l.CategoryAll, IsActive = true }, + new() { Category = ReviewCategory.Spelling, Label = l.CategorySpelling }, + new() { Category = ReviewCategory.Grammar, Label = l.CategoryGrammar }, + new() { Category = ReviewCategory.Punctuation, Label = l.CategoryPunctuation }, + new() { Category = ReviewCategory.Casing, Label = l.CategoryCasing }, + new() { Category = ReviewCategory.Other, Label = l.CategoryStyle }, + }; + + ApplyButtonText = string.Format(l.ApplyXFixes, 0); + UpdateSummary(); + } + + public void Initialize(Subtitle subtitle, SubtitleFormat? subtitleFormat) + { + _subtitle = subtitle; + _autoDetectedLanguageCode = LanguageAutoDetect.AutoDetectGoogleLanguage(subtitle); + } + + /// Asks the server for its languages - also the connection test, so errors are shown as status. + [RelayCommand] + private async Task RefreshLanguages() + { + var l = Se.Language.Tools.GrammarCheck; + var url = ServerUrl.Trim(); + StatusText = string.Format(l.Connecting, url); + + using var client = new LanguageToolClient(); + try + { + var languages = await client.GetLanguagesAsync(url, _windowCancellationTokenSource.Token); + PopulateLanguages(languages); + StatusText = string.Format(l.ConnectedXLanguages, languages.Count); + } + catch (Exception e) when (e is not OperationCanceledException) + { + StatusText = string.Format(l.ServerError, e.Message); + } + } + + /// Fills the language drop-down with what the server offers and picks the best entry. + public void PopulateLanguages(IReadOnlyList languages) + { + // Only keep the current pick when the list is being reloaded (the user pressed refresh after + // changing the server) - on the first load it is still the "Auto" placeholder, and the + // language detected from the subtitle beats leaving it to the server, which sees only a few + // short lines at a time. + var previous = _languagesLoaded ? SelectedLanguage?.LongCode : null; + var saved = Se.Settings.Tools.GrammarCheck.Language; + Languages.Clear(); + Languages.Add(MakeAutoLanguage()); + foreach (var language in languages.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase)) + { + if (Languages.All(x => x.LongCode != language.LongCode)) + { + Languages.Add(language); + } + } + + SelectedLanguage = FindLanguage(previous) + ?? FindLanguage(saved == LanguageToolLanguage.AutoCode ? null : saved) + ?? FindLanguage(_autoDetectedLanguageCode) + ?? Languages[0]; + + _languagesLoaded = languages.Count > 0; + } + + /// + /// Finds a language by long code ("en-US"), falling back to the first variant of a plain code + /// ("en"), which is what the subtitle auto-detect gives us. + /// + private LanguageToolLanguage? FindLanguage(string? code) + { + if (string.IsNullOrWhiteSpace(code)) + { + return null; + } + + var wanted = code.Trim(); + return Languages.FirstOrDefault(x => x.LongCode.Equals(wanted, StringComparison.OrdinalIgnoreCase)) + ?? Languages.FirstOrDefault(x => !x.IsAuto && x.Code.Equals(wanted, StringComparison.OrdinalIgnoreCase)); + } + + private static LanguageToolLanguage MakeAutoLanguage() + { + return new LanguageToolLanguage + { + Name = Se.Language.General.Auto, + Code = LanguageToolLanguage.AutoCode, + LongCode = LanguageToolLanguage.AutoCode, + }; + } + + partial void OnIsCheckingChanged(bool value) + { + IsNotChecking = !value; + } + + partial void OnMessageTextChanged(string value) + { + HasMessage = !string.IsNullOrEmpty(value); + } + + partial void OnSelectedSuggestionChanged(GrammarCheckSuggestionItem? value) + { + _updatingReplacementOptions = true; + try + { + ReplacementOptions.Clear(); + if (value == null) + { + SelectedReplacementOption = null; + HasReplacementOptions = false; + MessageText = string.Empty; + return; + } + + foreach (var replacement in value.Replacements) + { + ReplacementOptions.Add(replacement); + } + + SelectedReplacementOption = value.CanApply ? value.Replacement : null; + HasReplacementOptions = ReplacementOptions.Count > 1; + + var l = Se.Language.Tools.GrammarCheck; + var message = value.Message.Length > 0 ? value.Message : value.RuleId; + MessageText = value.CanApply + ? $"{string.Format(l.LineX, value.Number)}: {message}" + : $"{string.Format(l.LineX, value.Number)}: {message} - {l.NoAutomaticFix}"; + } + finally + { + _updatingReplacementOptions = false; + } + } + + partial void OnSelectedReplacementOptionChanged(string? value) + { + if (_updatingReplacementOptions || value == null || SelectedSuggestion == null) + { + return; + } + + SelectedSuggestion.Replacement = value; + } + + private void SaveSettings() + { + var settings = Se.Settings.Tools.GrammarCheck; + settings.ServerUrl = ServerUrl.Trim(); + settings.Language = SelectedLanguage?.LongCode ?? LanguageToolLanguage.AutoCode; + settings.Picky = IsPicky; + Se.SaveSettings(); + } + + [RelayCommand] + private async Task Check() + { + if (IsChecking || Window == null) + { + return; + } + + SaveSettings(); + var l = Se.Language.Tools.GrammarCheck; + + _cancellationTokenSource = new CancellationTokenSource(); + var ct = _cancellationTokenSource.Token; + + ClearSuggestions(); + ProgressValue = 0; + IsChecking = true; + + var lineIndexes = new List(); + for (var i = 0; i < _subtitle.Paragraphs.Count; i++) + { + if (!string.IsNullOrWhiteSpace(_subtitle.Paragraphs[i].Text)) + { + lineIndexes.Add(i); + } + } + + var settings = Se.Settings.Tools.GrammarCheck; + var batchSize = Math.Max(1, settings.MaxLinesPerBatch); + var options = new LanguageToolOptions + { + Language = SelectedLanguage?.LongCode ?? LanguageToolLanguage.AutoCode, + Picky = IsPicky, + DisabledRules = settings.DisabledRules, + Username = settings.Username, + ApiKey = settings.ApiKey, + }; + + using var client = new LanguageToolClient(); + var processedLines = 0; + var consecutiveErrors = 0; + + try + { + for (var start = 0; start < lineIndexes.Count; start += batchSize) + { + ct.ThrowIfCancellationRequested(); + + var batch = lineIndexes.GetRange(start, Math.Min(batchSize, lineIndexes.Count - start)); + StatusText = string.Format(l.CheckingLineXOfY, batch[0] + 1, _subtitle.Paragraphs.Count); + + var annotated = LanguageToolAnnotatedText.Build(batch.Select(i => _subtitle.Paragraphs[i].Text).ToList()); + if (!annotated.IsEmpty) + { + try + { + var matches = await client.CheckAsync(ServerUrl, annotated.Json, options, ct); + AddMatches(matches, annotated, batch); + consecutiveErrors = 0; + } + catch (Exception e) when (e is not OperationCanceledException) + { + consecutiveErrors++; + if (consecutiveErrors >= 3) + { + await MessageBox.Show(Window, Se.Language.General.Error, + string.Format(l.ServerError, e.Message), MessageBoxButtons.OK, MessageBoxIcon.Error); + break; + } + } + } + + processedLines += batch.Count; + ProgressValue = Math.Min(100.0, processedLines * 100.0 / Math.Max(1, lineIndexes.Count)); + } + + StatusText = _allSuggestions.Count == 0 && processedLines >= lineIndexes.Count + ? l.NoIssuesFound + : string.Format(l.CheckDone, _allSuggestions.Count, processedLines); + } + catch (OperationCanceledException) + { + StatusText = string.Format(l.CheckDone, _allSuggestions.Count, processedLines); + } + finally + { + ProgressValue = 100; + IsChecking = false; + } + } + + /// + /// Turns the matches of one batch into rows. holds the + /// paragraph each line of came from. + /// + public void AddMatches(IReadOnlyList matches, LanguageToolAnnotatedText annotated, IReadOnlyList paragraphIndexes) + { + foreach (var match in matches) + { + AddSuggestion(match, annotated, paragraphIndexes); + } + } + + /// + /// Turns one match into a row. Matches that cannot be mapped back to a single line - they crossed a + /// line break or a formatting tag - are dropped: applying those would break the tag or the break. + /// + private void AddSuggestion(LanguageToolMatch match, LanguageToolAnnotatedText annotated, IReadOnlyList batch) + { + if (!annotated.TryMapToLine(match.Offset, match.Length, out var lineIndex, out var lineOffset) || + lineIndex < 0 || lineIndex >= batch.Count) + { + return; + } + + var paragraphIndex = batch[lineIndex]; + if (paragraphIndex < 0 || paragraphIndex >= _subtitle.Paragraphs.Count) + { + return; + } + + var before = _subtitle.Paragraphs[paragraphIndex].Text; + if (lineOffset < 0 || lineOffset + match.Length > before.Length) + { + return; + } + + var fragment = before.Substring(lineOffset, match.Length); + var replacements = match.Replacements.Where(r => r != fragment).ToList(); + var category = GrammarCheckSuggestionItem.MapCategory(match.CategoryId, match.IssueType); + var item = new GrammarCheckSuggestionItem + { + Number = paragraphIndex + 1, + ParagraphIndex = paragraphIndex, + Offset = lineOffset, + Length = match.Length, + Category = category, + Before = before, + Fragment = fragment, + Message = match.Message, + ShortMessage = match.ShortMessage.Length > 0 ? match.ShortMessage : match.RuleDescription, + RuleId = match.RuleId, + Replacements = replacements, + Replacement = replacements.Count > 0 ? replacements[0] : string.Empty, + // Spelling/grammar/punctuation fixes are safe to tick by default; style is a matter of + // taste, so those start unticked even though they can be applied. + IsSelected = replacements.Count > 0 && category != ReviewCategory.Other, + }; + + item.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(GrammarCheckSuggestionItem.IsSelected)) + { + UpdateSummary(); + } + }; + + _allSuggestions.Add(item); + if (PassesFilter(item)) + { + Suggestions.Add(item); + } + + UpdateChipCounts(); + UpdateSummary(); + } + + private void ClearSuggestions() + { + _allSuggestions.Clear(); + Suggestions.Clear(); + foreach (var chip in FilterChips) + { + chip.Count = 0; + } + + MessageText = string.Empty; + UpdateSummary(); + } + + private bool PassesFilter(GrammarCheckSuggestionItem item) + { + var active = FilterChips.FirstOrDefault(c => c.IsActive); + return active?.Category == null || active.Category == item.Category; + } + + private void UpdateChipCounts() + { + foreach (var chip in FilterChips) + { + chip.Count = chip.Category == null + ? _allSuggestions.Count + : _allSuggestions.Count(s => s.Category == chip.Category); + } + } + + private void UpdateSummary() + { + var l = Se.Language.Tools.GrammarCheck; + var selected = SelectedCount; + SummaryText = string.Format(l.XIssuesYSelected, _allSuggestions.Count, selected); + ApplyButtonText = string.Format(l.ApplyXFixes, selected); + } + + [RelayCommand] + private void SetFilter(ReviewFilterChip chip) + { + foreach (var c in FilterChips) + { + c.IsActive = c == chip; + } + + Suggestions.Clear(); + foreach (var item in _allSuggestions.Where(PassesFilter)) + { + Suggestions.Add(item); + } + } + + [RelayCommand] + private void StopCheck() + { + _cancellationTokenSource.Cancel(); + } + + [RelayCommand] + private void SelectAll() + { + foreach (var item in _allSuggestions.Where(s => s.CanApply)) + { + item.IsSelected = true; + } + + UpdateSummary(); + } + + [RelayCommand] + private void InvertSelection() + { + foreach (var item in _allSuggestions.Where(s => s.CanApply)) + { + item.IsSelected = !item.IsSelected; + } + + UpdateSummary(); + } + + [RelayCommand] + private async Task ShowSettings() + { + if (Window == null) + { + return; + } + + await _windowService.ShowDialogAsync( + Window, vm => vm.Initialize()); + } + + [RelayCommand] + private void Ok() + { + SaveSettings(); + + FixedSubtitle = new Subtitle(_subtitle, false); + FixedCount = 0; + foreach (var group in _allSuggestions.Where(s => s.IsSelected && s.CanApply).GroupBy(s => s.ParagraphIndex)) + { + if (group.Key < 0 || group.Key >= FixedSubtitle.Paragraphs.Count) + { + continue; + } + + var paragraph = FixedSubtitle.Paragraphs[group.Key]; + var text = LanguageToolFix.Apply(paragraph.Text, + group.Select(s => new LanguageToolFixItem(s.Offset, s.Length, s.Replacement))); + if (text != paragraph.Text) + { + paragraph.Text = text; + FixedCount++; + } + } + + OkPressed = true; + _cancellationTokenSource.Cancel(); + Window?.Close(); + } + + [RelayCommand] + private void Cancel() + { + _cancellationTokenSource.Cancel(); + Window?.Close(); + } + + internal void OnLoaded() + { + if (!_languagesLoaded) + { + RefreshLanguagesCommand.Execute(null); + } + } + + internal void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + e.Handled = true; + _cancellationTokenSource.Cancel(); + Window?.Close(); + } + else if (UiUtil.IsHelp(e)) + { + e.Handled = true; + UiUtil.ShowHelp("features/grammar-check"); + } + } + + internal void OnClosing() + { + _cancellationTokenSource.Cancel(); + _windowCancellationTokenSource.Cancel(); + UiUtil.SaveWindowPosition(Window); + } +} diff --git a/src/ui/Features/Tools/GrammarCheck/GrammarCheckWindow.cs b/src/ui/Features/Tools/GrammarCheck/GrammarCheckWindow.cs new file mode 100644 index 00000000000..c21a52c9f9a --- /dev/null +++ b/src/ui/Features/Tools/GrammarCheck/GrammarCheckWindow.cs @@ -0,0 +1,427 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.Data; +using Avalonia.Layout; +using Avalonia.Media; +using Nikse.SubtitleEdit.Features.Files.Compare; +using Nikse.SubtitleEdit.Features.Tools.AiReview; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using System.ComponentModel; + +namespace Nikse.SubtitleEdit.Features.Tools.GrammarCheck; + +public class GrammarCheckWindow : Window +{ + public GrammarCheckWindow(GrammarCheckViewModel vm) + { + UiUtil.InitializeWindow(this, GetType().Name); + Title = Se.Language.Tools.GrammarCheck.Title; + Width = 1024; + Height = 720; + MinWidth = 800; + MinHeight = 500; + CanResize = true; + vm.Window = this; + DataContext = vm; + + var l = Se.Language.Tools.GrammarCheck; + + // ---------- toolbar ---------- + var textBoxServer = UiUtil.MakeTextBox(260, vm, nameof(vm.ServerUrl)) + .WithAccessibleName(l.Server); + ToolTip.SetTip(textBoxServer, l.ServerHint); + + var buttonRefresh = UiUtil.MakeButton(vm.RefreshLanguagesCommand, IconNames.Refresh) + .WithAccessibleName(l.TestConnection); + ToolTip.SetTip(buttonRefresh, l.TestConnection); + + var comboLanguage = UiUtil.MakeComboBox(vm.Languages, vm, nameof(vm.SelectedLanguage)) + .WithAccessibleName(Se.Language.General.Language); + comboLanguage.MinWidth = 190; + + var checkBoxPicky = UiUtil.MakeCheckBox(l.Picky, vm, nameof(vm.IsPicky)); + ToolTip.SetTip(checkBoxPicky, l.PickyHint); + + var buttonSettings = UiUtil.MakeButton(Se.Language.General.Settings, vm.ShowSettingsCommand) + .WithIconLeft(IconNames.Settings); + + var toolbar = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,Auto,Auto,Auto,Auto,Auto,*,Auto"), + ColumnSpacing = 8, + }; + toolbar.Add(UiUtil.MakeTextBlock(l.Server), 0, 0); + toolbar.Add(textBoxServer, 0, 1); + toolbar.Add(buttonRefresh, 0, 2); + toolbar.Add(UiUtil.MakeTextBlock(Se.Language.General.Language).WithMarginLeft(6), 0, 3); + toolbar.Add(comboLanguage, 0, 4); + toolbar.Add(checkBoxPicky, 0, 5); + toolbar.Add(buttonSettings, 0, 7); + + // ---------- filter chips ---------- + var chipsItems = new ItemsControl + { + ItemsSource = vm.FilterChips, + ItemsPanel = new FuncTemplate(() => new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }), + ItemTemplate = new FuncDataTemplate((chip, _) => + { + var content = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + VerticalAlignment = VerticalAlignment.Center, + }; + if (chip?.Category != null) + { + content.Children.Add(new Avalonia.Controls.Shapes.Ellipse + { + Width = 7, + Height = 7, + VerticalAlignment = VerticalAlignment.Center, + Fill = ReviewSuggestionItem.GetBrushForCategory(chip.Category.Value), + }); + } + + var chipText = new TextBlock { VerticalAlignment = VerticalAlignment.Center }; + chipText.Bind(TextBlock.TextProperty, new Binding(nameof(ReviewFilterChip.Display))); + content.Children.Add(chipText); + + var toggle = new ToggleButton + { + Padding = new Thickness(10, 3), + CornerRadius = new CornerRadius(12), + Command = vm.SetFilterCommand, + CommandParameter = chip, + Content = content, + [!ToggleButton.IsCheckedProperty] = new Binding(nameof(ReviewFilterChip.IsActive)), + }; + AutomationProperties.SetName(toggle, chip?.Label ?? string.Empty); + return toggle; + }), + }; + + // ---------- issues grid ---------- + var dataGrid = new DataGrid + { + AutoGenerateColumns = false, + CanUserResizeColumns = true, + CanUserSortColumns = true, + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch, + Width = double.NaN, + Height = double.NaN, + DataContext = vm, + ItemsSource = vm.Suggestions, + IsReadOnly = false, + Columns = + { + new DataGridTemplateColumn + { + Header = Se.Language.General.Apply, + CellTheme = UiUtil.DataGridNoBorderNoPaddingCellTheme, + CellTemplate = new FuncDataTemplate((item, _) => new Border + { + Background = Brushes.Transparent, + Padding = new Thickness(4), + Child = new CheckBox + { + Focusable = false, + // Rules without a replacement are information only - nothing to tick. + IsEnabled = item?.CanApply ?? false, + [!ToggleButton.IsCheckedProperty] = new Binding(nameof(GrammarCheckSuggestionItem.IsSelected)), + [!AutomationProperties.NameProperty] = new Binding(nameof(GrammarCheckSuggestionItem.CategoryDisplay)), + HorizontalAlignment = HorizontalAlignment.Center, + }, + }), + Width = new DataGridLength(1, DataGridLengthUnitType.Auto), + }, + new DataGridTextColumn + { + Header = Se.Language.General.NumberSymbol, + CellTheme = UiUtil.DataGridNoBorderNoPaddingCellTheme, + Binding = new Binding(nameof(GrammarCheckSuggestionItem.Number)), + IsReadOnly = true, + }, + new DataGridTemplateColumn + { + Header = Se.Language.Tools.FixCommonErrors.Action, + CellTheme = UiUtil.DataGridNoBorderNoPaddingCellTheme, + CellTemplate = new FuncDataTemplate((item, _) => + { + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + VerticalAlignment = VerticalAlignment.Center, + }; + if (item != null) + { + panel.Children.Add(new Border + { + Background = item.CategoryBackgroundBrush, + CornerRadius = new CornerRadius(5), + Padding = new Thickness(7, 2), + VerticalAlignment = VerticalAlignment.Center, + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 5, + Children = + { + new Optris.Icons.Avalonia.Icon + { + Value = item.CategoryIconName, + FontSize = 13, + Foreground = item.CategoryBrush, + VerticalAlignment = VerticalAlignment.Center, + }, + new TextBlock + { + Text = item.CategoryDisplay, + FontSize = 12, + Foreground = item.CategoryBrush, + VerticalAlignment = VerticalAlignment.Center, + }, + }, + }, + }); + } + + return new Border + { + Background = Brushes.Transparent, + Padding = new Thickness(4), + Child = panel, + }; + }), + IsReadOnly = true, + Width = new DataGridLength(1, DataGridLengthUnitType.Auto), + }, + new DataGridTextColumn + { + Header = l.Issue, + CellTheme = UiUtil.DataGridNoBorderNoPaddingCellTheme, + Binding = new Binding(nameof(GrammarCheckSuggestionItem.IssueDisplay)), + IsReadOnly = true, + Width = new DataGridLength(0.9, DataGridLengthUnitType.Star), + }, + new DataGridTemplateColumn + { + Header = Se.Language.General.Before, + CellTheme = UiUtil.DataGridNoBorderNoPaddingCellTheme, + CellTemplate = new FuncDataTemplate((item, _) => MakeDiffCell(item, showAfter: false)), + IsReadOnly = true, + Width = new DataGridLength(1, DataGridLengthUnitType.Star), + }, + new DataGridTemplateColumn + { + Header = Se.Language.General.After, + CellTheme = UiUtil.DataGridNoBorderNoPaddingCellTheme, + CellTemplate = new FuncDataTemplate((item, _) => MakeDiffCell(item, showAfter: true)), + IsReadOnly = true, + Width = new DataGridLength(1, DataGridLengthUnitType.Star), + }, + }, + }; + AutomationProperties.SetName(dataGrid, l.Title); + dataGrid.Bind(DataGrid.SelectedItemProperty, new Binding(nameof(vm.SelectedSuggestion))); + _ = new DataGridCheckboxMultiSelect(dataGrid, + item => item.IsSelected, + (item, value) => item.IsSelected = value, + item => item.CanApply); + + var borderGrid = UiUtil.MakeBorderForControlNoPadding(dataGrid); + + // ---------- progress ---------- + var progressBar = new ProgressBar + { + Minimum = 0, + Maximum = 100, + Height = 6, + VerticalAlignment = VerticalAlignment.Center, + [!RangeBase.ValueProperty] = new Binding(nameof(vm.ProgressValue)), + }; + var statusText = MakeBoundTextBlock(nameof(vm.StatusText)); + statusText.Opacity = 0.8; + + var progressRow = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,Auto,*"), + ColumnSpacing = 10, + }; + progressRow.Add(new Optris.Icons.Avalonia.Icon + { + Value = "mdi-spellcheck", + FontSize = 15, + Opacity = 0.8, + VerticalAlignment = VerticalAlignment.Center, + }, 0, 0); + progressRow.Add(statusText, 0, 1); + progressRow.Add(progressBar, 0, 2); + + // ---------- message strip ---------- + var messageTextBlock = MakeBoundTextBlock(nameof(vm.MessageText)); + messageTextBlock.Opacity = 0.8; + messageTextBlock.TextWrapping = TextWrapping.Wrap; + + // Only shown when LanguageTool offers more than one way to fix the selected issue. + var comboReplacement = UiUtil.MakeComboBox(vm.ReplacementOptions, vm, nameof(vm.SelectedReplacementOption), + nameof(vm.HasReplacementOptions)) + .WithAccessibleName(l.Replacement); + comboReplacement.MinWidth = 160; + + var messageStrip = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 7, + MinHeight = 20, + Children = + { + new Optris.Icons.Avalonia.Icon + { + Value = "mdi-message-text-outline", + FontSize = 14, + Opacity = 0.7, + VerticalAlignment = VerticalAlignment.Center, + }, + messageTextBlock, + comboReplacement, + }, + }; + messageStrip.Bind(IsVisibleProperty, new Binding(nameof(vm.HasMessage))); + + // ---------- bottom bar ---------- + var summaryText = MakeBoundTextBlock(nameof(vm.SummaryText)); + summaryText.Opacity = 0.8; + + var leftButtons = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 5, + Children = + { + summaryText.WithMarginRight(10), + UiUtil.MakeButton(Se.Language.General.SelectAll, vm.SelectAllCommand), + UiUtil.MakeButton(Se.Language.General.InvertSelection, vm.InvertSelectionCommand), + }, + }; + + var buttonCheck = UiUtil.MakeButton(l.Check, vm.CheckCommand) + .WithIconLeft("fa-solid fa-spell-check"); + buttonCheck.Bind(IsVisibleProperty, new Binding(nameof(vm.IsNotChecking))); + + var buttonStop = UiUtil.MakeButton(l.Stop, vm.StopCheckCommand) + .WithIconLeft("fa-solid fa-stop"); + buttonStop.Bind(IsVisibleProperty, new Binding(nameof(vm.IsChecking))); + + var buttonApply = UiUtil.MakeButton(string.Empty, vm.OkCommand); + buttonApply.Bind(ContentControl.ContentProperty, new Binding(nameof(vm.ApplyButtonText))); + buttonApply.WithIconLeft("fa-solid fa-check"); + + var buttonCancel = UiUtil.MakeButtonCancel(vm.CancelCommand); + + var bottomBar = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), + }; + bottomBar.Add(leftButtons, 0, 0); + bottomBar.Add(UiUtil.MakeButtonBar(buttonCheck, buttonStop, buttonApply, buttonCancel), 0, 2); + + // ---------- layout ---------- + var grid = new Grid + { + RowDefinitions = + { + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + new RowDefinition { Height = new GridLength(1, GridUnitType.Auto) }, + }, + ColumnDefinitions = + { + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + }, + Margin = UiUtil.MakeWindowMargin(), + RowSpacing = 8, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + + grid.Add(toolbar, 0, 0); + grid.Add(chipsItems, 1, 0); + grid.Add(borderGrid, 2, 0); + grid.Add(progressRow, 3, 0); + grid.Add(messageStrip, 4, 0); + grid.Add(bottomBar, 5, 0); + + Content = grid; + + Loaded += delegate + { + buttonCheck.Focus(); + UiUtil.RestoreWindowPosition(this); + vm.OnLoaded(); + }; + Closing += delegate { vm.OnClosing(); }; + KeyDown += (_, e) => vm.OnKeyDown(e); + } + + /// + /// A word-diff cell. The diff is built in code rather than bound, so the cell has to redo it when + /// the row's replacement changes - the picker under the grid can swap it while the row is on screen. + /// + private static Control MakeDiffCell(GrammarCheckSuggestionItem? item, bool showAfter) + { + var border = new Border + { + Background = Brushes.Transparent, + Padding = new Thickness(4), + }; + + if (item == null) + { + return border; + } + + void Refresh() + { + var (beforeBlock, afterBlock) = TextDiffHighlighter.CompareReplacement(item.Before, item.After); + border.Child = showAfter ? afterBlock : beforeBlock; + } + + void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(GrammarCheckSuggestionItem.After)) + { + Refresh(); + } + } + + Refresh(); + + // Rows are recycled, so unsubscribe on detach - and defensively before subscribing. + border.AttachedToVisualTree += (_, _) => + { + item.PropertyChanged -= OnItemPropertyChanged; + item.PropertyChanged += OnItemPropertyChanged; + }; + border.DetachedFromVisualTree += (_, _) => item.PropertyChanged -= OnItemPropertyChanged; + + return border; + } + + private static TextBlock MakeBoundTextBlock(string textPropertyPath) + { + var textBlock = new TextBlock + { + VerticalAlignment = VerticalAlignment.Center, + }; + textBlock.Bind(TextBlock.TextProperty, new Binding(textPropertyPath)); + return textBlock; + } +} diff --git a/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs b/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs index 2db2c1f141a..02b70515832 100644 --- a/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs +++ b/src/ui/Logic/Config/Language/Main/LanguageMainMenu.cs @@ -51,6 +51,7 @@ public class LanguageMainMenu public string ChangeCasing { get; set; } public string ChangeFormatting { get; set; } public string AiReview { get; set; } + public string GrammarCheck { get; set; } public string FixCommonErrors { get; set; } public string CheckAndFixNetflixErrors { get; set; } public string MakeEmptyTranslationFromCurrentSubtitle { get; set; } @@ -184,6 +185,7 @@ public LanguageMainMenu() AdjustDurations = "_Adjust durations..."; ApplyDurationLimits = "Apply duration _limits..."; AiReview = "AI review..."; + GrammarCheck = "Grammar chec_k (LanguageTool)..."; FixCommonErrors = "_Fix common errors..."; CheckAndFixNetflixErrors = "Check and fix Netfli_x errors..."; MakeEmptyTranslationFromCurrentSubtitle = "Make new _empty translation from current subtitle"; diff --git a/src/ui/Logic/Config/Language/Tools/LanguageGrammarCheck.cs b/src/ui/Logic/Config/Language/Tools/LanguageGrammarCheck.cs new file mode 100644 index 00000000000..0d24deccc29 --- /dev/null +++ b/src/ui/Logic/Config/Language/Tools/LanguageGrammarCheck.cs @@ -0,0 +1,73 @@ +namespace Nikse.SubtitleEdit.Logic.Config.Language.Tools; + +public class LanguageGrammarCheck +{ + public string Title { get; set; } + public string Check { get; set; } + public string Stop { get; set; } + public string Server { get; set; } + public string ServerHint { get; set; } + public string TestConnection { get; set; } + public string Connecting { get; set; } + public string ConnectedXLanguages { get; set; } + public string Picky { get; set; } + public string PickyHint { get; set; } + public string SettingsTitle { get; set; } + public string SettingsInfo { get; set; } + public string Username { get; set; } + public string DisabledRules { get; set; } + public string DisabledRulesHint { get; set; } + public string MaxLinesPerBatch { get; set; } + public string Issue { get; set; } + public string Replacement { get; set; } + public string NoAutomaticFix { get; set; } + public string ApplyXFixes { get; set; } + public string XIssuesYSelected { get; set; } + public string CheckingLineXOfY { get; set; } + public string CheckDone { get; set; } + public string NoIssuesFound { get; set; } + public string CategoryAll { get; set; } + public string CategorySpelling { get; set; } + public string CategoryGrammar { get; set; } + public string CategoryPunctuation { get; set; } + public string CategoryCasing { get; set; } + public string CategoryStyle { get; set; } + public string LineX { get; set; } + public string ServerError { get; set; } + + public LanguageGrammarCheck() + { + Title = "Grammar check (LanguageTool)"; + Check = "Check"; + Stop = "Stop"; + Server = "Server"; + ServerHint = "Base address of the LanguageTool server, e.g. https://api.languagetool.org or your own installation"; + TestConnection = "Test connection and reload languages"; + Connecting = "Connecting to {0}..."; + ConnectedXLanguages = "Connected - {0} languages available"; + Picky = "Picky"; + PickyHint = "Also report the stricter style rules LanguageTool leaves off by default"; + SettingsTitle = "LanguageTool settings"; + SettingsInfo = "Username and API key are only needed for a premium account or a server that requires them."; + Username = "User name"; + DisabledRules = "Disabled rules"; + DisabledRulesHint = "Comma separated rule ids to ignore, e.g. WHITESPACE_RULE,UPPERCASE_SENTENCE_START"; + MaxLinesPerBatch = "Lines per request"; + Issue = "Issue"; + Replacement = "Replacement"; + NoAutomaticFix = "no automatic fix - correct this one by hand"; + ApplyXFixes = "Apply {0} fixes"; + XIssuesYSelected = "{0} issues - {1} selected"; + CheckingLineXOfY = "Checking line {0} of {1}..."; + CheckDone = "Check done - {0} issues in {1} lines"; + NoIssuesFound = "No issues found"; + CategoryAll = "All"; + CategorySpelling = "Spelling"; + CategoryGrammar = "Grammar"; + CategoryPunctuation = "Punctuation"; + CategoryCasing = "Casing"; + CategoryStyle = "Style"; + LineX = "Line {0}"; + ServerError = "The LanguageTool server could not be reached: {0}"; + } +} diff --git a/src/ui/Logic/Config/Language/Tools/LanguageTools.cs b/src/ui/Logic/Config/Language/Tools/LanguageTools.cs index cb13337a2fa..2b9d5d98da7 100644 --- a/src/ui/Logic/Config/Language/Tools/LanguageTools.cs +++ b/src/ui/Logic/Config/Language/Tools/LanguageTools.cs @@ -4,6 +4,7 @@ public class LanguageTools { public LanguageAiReview AiReview { get; set; } = new(); public LanguageAiAssistant AiAssistant { get; set; } = new(); + public LanguageGrammarCheck GrammarCheck { get; set; } = new(); public LanguageFixCommonErrors FixCommonErrors { get; set; } = new(); public LanguageAdjustDisplayDurations AdjustDurations { get; set; } = new(); public LanguageApplyDurationLimits ApplyDurationLimits { get; set; } = new(); diff --git a/src/ui/Logic/Config/SeGrammarCheck.cs b/src/ui/Logic/Config/SeGrammarCheck.cs new file mode 100644 index 00000000000..0eb201d57d2 --- /dev/null +++ b/src/ui/Logic/Config/SeGrammarCheck.cs @@ -0,0 +1,34 @@ +using Nikse.SubtitleEdit.UiLogic.Grammar; + +namespace Nikse.SubtitleEdit.Logic.Config; + +public class SeGrammarCheck +{ + /// Base url of the LanguageTool server - the public API or a self-hosted one. + public string ServerUrl { get; set; } + + /// A long code like "en-US", or "auto" for server side detection. + public string Language { get; set; } + + public bool Picky { get; set; } + + /// Comma separated rule ids to switch off, e.g. "WHITESPACE_RULE". + public string DisabledRules { get; set; } + + public string Username { get; set; } + public string ApiKey { get; set; } + + /// Lines per request - a whole subtitle in one call would give no progress and no way to stop. + public int MaxLinesPerBatch { get; set; } + + public SeGrammarCheck() + { + ServerUrl = LanguageToolClient.DefaultServerUrl; + Language = LanguageToolLanguage.AutoCode; + Picky = false; + DisabledRules = string.Empty; + Username = string.Empty; + ApiKey = string.Empty; + MaxLinesPerBatch = 25; + } +} diff --git a/src/ui/Logic/Config/SeTools.cs b/src/ui/Logic/Config/SeTools.cs index 29e4d6a25b3..8d06403009b 100644 --- a/src/ui/Logic/Config/SeTools.cs +++ b/src/ui/Logic/Config/SeTools.cs @@ -9,6 +9,7 @@ public class SeTools public SeAiReview AiReview { get; set; } = new(); public SeAudioToText AudioToText { get; set; } = new(); public SeConvertActors ConvertActors { get; set; } = new(); + public SeGrammarCheck GrammarCheck { get; set; } = new(); public SeFixCommonErrors FixCommonErrors { get; set; } = new(); public SeFixNetflixErrors FixNetflixErrors { get; set; } = new(); public SeAdjustDisplayDurations AdjustDurations { get; set; } = new(); diff --git a/tests/UI/Features/Tools/GrammarCheck/GrammarCheckViewModelTests.cs b/tests/UI/Features/Tools/GrammarCheck/GrammarCheckViewModelTests.cs new file mode 100644 index 00000000000..f03875058d9 --- /dev/null +++ b/tests/UI/Features/Tools/GrammarCheck/GrammarCheckViewModelTests.cs @@ -0,0 +1,395 @@ +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.LogicalTree; +using Nikse.SubtitleEdit.Core.Common; +using Nikse.SubtitleEdit.Features.Tools.GrammarCheck; +using Nikse.SubtitleEdit.Logic; +using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.UiLogic.Grammar; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace UITests.Features.Tools.GrammarCheck; + +/// +/// The path from a LanguageTool match to a changed subtitle line runs through two offset mappings - +/// batch text to line, line to paragraph - and several matches can land on the same line. These tests +/// drive that path with canned matches, so it is covered without a server. +/// +public class GrammarCheckViewModelTests +{ + private sealed class NullServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private static GrammarCheckViewModel MakeViewModel(Subtitle subtitle) + { + var viewModel = new GrammarCheckViewModel(new WindowService(new NullServiceProvider())); + viewModel.Initialize(subtitle, null); + return viewModel; + } + + private static Subtitle MakeSubtitle(params string[] texts) + { + var subtitle = new Subtitle(); + for (var i = 0; i < texts.Length; i++) + { + subtitle.Paragraphs.Add(new Paragraph(texts[i], i * 1000, i * 1000 + 900)); + } + + return subtitle; + } + + private static LanguageToolMatch MakeMatch(int offset, int length, string[] replacements, + string categoryId = "GRAMMAR", string issueType = "grammar") + { + return new LanguageToolMatch + { + Offset = offset, + Length = length, + Message = "Test message", + ShortMessage = "Test", + RuleId = "TEST_RULE", + CategoryId = categoryId, + IssueType = issueType, + Replacements = replacements, + }; + } + + [Fact] + public void AddMatches_MapsAMatchOntoItsParagraphWithoutTouchingTheTags() + { + var subtitle = MakeSubtitle("He go to school", "I has a apple"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(subtitle.Paragraphs.Select(p => p.Text).ToList()); + + viewModel.AddMatches( + new[] { MakeMatch(annotated.Text.IndexOf("go", StringComparison.Ordinal), 2, new[] { "goes" }) }, + annotated, + new[] { 0, 1 }); + + var item = Assert.Single(viewModel.Suggestions); + Assert.Equal(1, item.Number); + Assert.Equal(0, item.ParagraphIndex); + Assert.Equal("go", item.Fragment); + Assert.Equal("He goes to school", item.After); + Assert.True(item.IsSelected); + } + + [Fact] + public void AddMatches_SecondLine_KeepsTheParagraphNumber() + { + var subtitle = MakeSubtitle("He goes to school.", "I has a apple"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(subtitle.Paragraphs.Select(p => p.Text).ToList()); + + viewModel.AddMatches( + new[] { MakeMatch(annotated.Text.IndexOf("has", StringComparison.Ordinal), 3, new[] { "have" }) }, + annotated, + new[] { 0, 1 }); + + var item = Assert.Single(viewModel.Suggestions); + Assert.Equal(2, item.Number); + Assert.Equal(1, item.ParagraphIndex); + Assert.Equal("I have a apple", item.After); + } + + [Fact] + public void AddMatches_BatchDoesNotStartAtTheFirstParagraph_UsesTheGivenIndexes() + { + var subtitle = MakeSubtitle("One.", "Two.", "I has a apple"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(new List { subtitle.Paragraphs[2].Text }); + + viewModel.AddMatches( + new[] { MakeMatch(annotated.Text.IndexOf("has", StringComparison.Ordinal), 3, new[] { "have" }) }, + annotated, + new[] { 2 }); + + var item = Assert.Single(viewModel.Suggestions); + Assert.Equal(3, item.Number); + Assert.Equal(2, item.ParagraphIndex); + } + + [Fact] + public void AddMatches_MatchCrossingATag_IsDropped() + { + var subtitle = MakeSubtitle("a apple"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(new List { subtitle.Paragraphs[0].Text }); + + viewModel.AddMatches(new[] { MakeMatch(0, 10, new[] { "an apple" }) }, annotated, new[] { 0 }); + + Assert.Empty(viewModel.Suggestions); + } + + [Fact] + public void AddMatches_RuleWithoutAReplacement_CannotBeApplied() + { + var subtitle = MakeSubtitle("He go to school"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(new List { subtitle.Paragraphs[0].Text }); + + viewModel.AddMatches(new[] { MakeMatch(3, 2, Array.Empty()) }, annotated, new[] { 0 }); + + var item = Assert.Single(viewModel.Suggestions); + Assert.False(item.CanApply); + Assert.False(item.IsSelected); + Assert.Equal(item.Before, item.After); + } + + [Fact] + public void AddMatches_StyleIssues_StartUnticked() + { + var subtitle = MakeSubtitle("He is very very tired."); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(new List { subtitle.Paragraphs[0].Text }); + + viewModel.AddMatches( + new[] { MakeMatch(6, 9, new[] { "is very" }, "STYLE", "style") }, + annotated, + new[] { 0 }); + + var item = Assert.Single(viewModel.Suggestions); + Assert.True(item.CanApply); + Assert.False(item.IsSelected); + } + + [Fact] + public void Ok_AppliesTheTickedFixesAndCountsTheChangedLines() + { + var subtitle = MakeSubtitle("He go to school", "I has a apple"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(subtitle.Paragraphs.Select(p => p.Text).ToList()); + + viewModel.AddMatches(new[] + { + MakeMatch(annotated.Text.IndexOf("go", StringComparison.Ordinal), 2, new[] { "goes" }), + MakeMatch(annotated.Text.IndexOf("has", StringComparison.Ordinal), 3, new[] { "have" }), + }, annotated, new[] { 0, 1 }); + + viewModel.OkCommand.Execute(null); + + Assert.True(viewModel.OkPressed); + Assert.Equal(2, viewModel.FixedCount); + Assert.Equal("He goes to school", viewModel.FixedSubtitle.Paragraphs[0].Text); + Assert.Equal("I have a apple", viewModel.FixedSubtitle.Paragraphs[1].Text); + } + + [Fact] + public void Ok_UntickedFix_LeavesTheLineAlone() + { + var subtitle = MakeSubtitle("He go to school"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(new List { subtitle.Paragraphs[0].Text }); + + viewModel.AddMatches(new[] { MakeMatch(3, 2, new[] { "goes" }) }, annotated, new[] { 0 }); + viewModel.Suggestions[0].IsSelected = false; + viewModel.OkCommand.Execute(null); + + Assert.Equal(0, viewModel.FixedCount); + Assert.Equal("He go to school", viewModel.FixedSubtitle.Paragraphs[0].Text); + } + + [Fact] + public void Ok_TwoFixesInOneLine_BothLandCorrectly() + { + var subtitle = MakeSubtitle("I has a apple"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(new List { subtitle.Paragraphs[0].Text }); + + viewModel.AddMatches(new[] + { + MakeMatch(2, 3, new[] { "have" }), + MakeMatch(6, 1, new[] { "an" }), + }, annotated, new[] { 0 }); + + viewModel.OkCommand.Execute(null); + + Assert.Equal(1, viewModel.FixedCount); + Assert.Equal("I have an apple", viewModel.FixedSubtitle.Paragraphs[0].Text); + } + + [Fact] + public void Ok_AlternativeReplacementPicked_IsTheOneApplied() + { + var subtitle = MakeSubtitle("He go to school"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(new List { subtitle.Paragraphs[0].Text }); + + viewModel.AddMatches(new[] { MakeMatch(3, 2, new[] { "goes", "went" }) }, annotated, new[] { 0 }); + viewModel.SelectedSuggestion = viewModel.Suggestions[0]; + viewModel.SelectedReplacementOption = "went"; + viewModel.OkCommand.Execute(null); + + Assert.Equal("He went to school", viewModel.FixedSubtitle.Paragraphs[0].Text); + } + + [Fact] + public void PickingAnotherReplacement_RaisesAfterSoTheGridCanFollow() + { + var subtitle = MakeSubtitle("He go to school"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(new List { subtitle.Paragraphs[0].Text }); + + viewModel.AddMatches(new[] { MakeMatch(3, 2, new[] { "goes", "went" }) }, annotated, new[] { 0 }); + var item = viewModel.Suggestions[0]; + var raised = new List(); + item.PropertyChanged += (_, e) => raised.Add(e.PropertyName); + + viewModel.SelectedSuggestion = item; + viewModel.SelectedReplacementOption = "went"; + + Assert.Contains(nameof(GrammarCheckSuggestionItem.After), raised); + Assert.Equal("He went to school", item.After); + } + + [Fact] + public void SelectingARow_ListsEveryReplacementAsAnOption() + { + var subtitle = MakeSubtitle("He go to school"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(new List { subtitle.Paragraphs[0].Text }); + + viewModel.AddMatches(new[] { MakeMatch(3, 2, new[] { "goes", "went" }) }, annotated, new[] { 0 }); + viewModel.SelectedSuggestion = viewModel.Suggestions[0]; + + Assert.Equal(new[] { "goes", "went" }, viewModel.ReplacementOptions); + Assert.Equal("goes", viewModel.SelectedReplacementOption); + Assert.True(viewModel.HasReplacementOptions); + Assert.Contains("Test message", viewModel.MessageText); + } + + [Fact] + public void SetFilter_ShowsOnlyTheChosenCategory() + { + var subtitle = MakeSubtitle("He go to school", "i has a apple"); + var viewModel = MakeViewModel(subtitle); + var annotated = LanguageToolAnnotatedText.Build(subtitle.Paragraphs.Select(p => p.Text).ToList()); + + viewModel.AddMatches(new[] + { + MakeMatch(3, 2, new[] { "goes" }), + MakeMatch(annotated.Text.IndexOf("i has", StringComparison.Ordinal), 1, new[] { "I" }, "CASING", "uppercase"), + }, annotated, new[] { 0, 1 }); + + Assert.Equal(2, viewModel.Suggestions.Count); + + var casingChip = viewModel.FilterChips.First(c => c.Label == Se.Language.Tools.GrammarCheck.CategoryCasing); + Assert.Equal(1, casingChip.Count); + viewModel.SetFilterCommand.Execute(casingChip); + + Assert.Equal("I", Assert.Single(viewModel.Suggestions).Replacement); + } + + private static List SampleLanguages() + { + return new List + { + new() { Name = "English", Code = "en", LongCode = "en" }, + new() { Name = "English (US)", Code = "en", LongCode = "en-US" }, + new() { Name = "German (Germany)", Code = "de", LongCode = "de-DE" }, + }; + } + + private static Subtitle MakeEnglishSubtitle() + { + return MakeSubtitle( + "I have not seen him since the day we left the city.", + "He said he would come back for us before the winter.", + "But that was a long time ago, and nothing has changed."); + } + + [Fact] + public void PopulateLanguages_NothingChosenYet_UsesTheSubtitleLanguage() + { + var settings = Se.Settings.Tools.GrammarCheck; + var saved = settings.Language; + try + { + settings.Language = "auto"; + var subtitle = MakeEnglishSubtitle(); + Assert.Equal("en", LanguageAutoDetect.AutoDetectGoogleLanguage(subtitle)); // guards the fixture + var viewModel = MakeViewModel(subtitle); + + viewModel.PopulateLanguages(SampleLanguages()); + + Assert.Equal("en", viewModel.SelectedLanguage?.LongCode); + } + finally + { + settings.Language = saved; + } + } + + [Fact] + public void PopulateLanguages_ASavedLanguage_WinsOverTheDetectedOne() + { + var settings = Se.Settings.Tools.GrammarCheck; + var saved = settings.Language; + try + { + settings.Language = "de-DE"; + var viewModel = MakeViewModel(MakeEnglishSubtitle()); + + viewModel.PopulateLanguages(SampleLanguages()); + + Assert.Equal("de-DE", viewModel.SelectedLanguage?.LongCode); + } + finally + { + settings.Language = saved; + } + } + + [Fact] + public void PopulateLanguages_Reload_KeepsWhatTheUserPicked() + { + var settings = Se.Settings.Tools.GrammarCheck; + var saved = settings.Language; + try + { + settings.Language = "auto"; + var viewModel = MakeViewModel(MakeEnglishSubtitle()); + viewModel.PopulateLanguages(SampleLanguages()); + viewModel.SelectedLanguage = viewModel.Languages.First(x => x.LongCode == "en-US"); + + viewModel.PopulateLanguages(SampleLanguages()); + + Assert.Equal("en-US", viewModel.SelectedLanguage?.LongCode); + } + finally + { + settings.Language = saved; + } + } + + [Fact] + public void PopulateLanguages_AlwaysOffersAutoFirst() + { + var viewModel = MakeViewModel(MakeEnglishSubtitle()); + + viewModel.PopulateLanguages(SampleLanguages()); + + Assert.True(viewModel.Languages[0].IsAuto); + Assert.Equal(4, viewModel.Languages.Count); + } + + [AvaloniaFact] + public void Window_ShowsTheServerBoxAndTheCheckButton() + { + var viewModel = new GrammarCheckViewModel(new WindowService(new NullServiceProvider())); + var window = new GrammarCheckWindow(viewModel); + + // WithIconLeft swaps the button text for an icon+text panel and keeps the text as its UIA name + var buttons = window.GetLogicalDescendants().OfType