1 // Copyright (c) 2011, Thomas Goyne <plorkyeran@aegisub.org>
2 //
3 // Permission to use, copy, modify, and distribute this software for any
4 // purpose with or without fee is hereby granted, provided that the above
5 // copyright notice and this permission notice appear in all copies.
6 //
7 // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8 // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9 // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10 // ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11 // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12 // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13 // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
14 //
15 // Aegisub Project http://www.aegisub.org/
16 
17 #include "ass_dialogue.h"
18 #include "ass_file.h"
19 #include "compat.h"
20 #include "dialog_manager.h"
21 #include "help_button.h"
22 #include "include/aegisub/context.h"
23 #include "include/aegisub/spellchecker.h"
24 #include "libresrc/libresrc.h"
25 #include "options.h"
26 #include "selection_controller.h"
27 #include "text_selection_controller.h"
28 
29 #include <libaegisub/ass/dialogue_parser.h>
30 #include <libaegisub/exception.h>
31 #include <libaegisub/spellchecker.h>
32 
33 #include <boost/locale/conversion.hpp>
34 #include <map>
35 #include <memory>
36 #include <set>
37 #include <wx/arrstr.h>
38 #include <wx/checkbox.h>
39 #include <wx/combobox.h>
40 #include <wx/dialog.h>
41 #include <wx/intl.h>
42 #include <wx/listbox.h>
43 #include <wx/msgdlg.h>
44 #include <wx/sizer.h>
45 #include <wx/stattext.h>
46 #include <wx/textctrl.h>
47 
48 namespace {
49 class DialogSpellChecker final : public wxDialog {
50 	agi::Context *context; ///< The project context
51 	std::unique_ptr<agi::SpellChecker> spellchecker; ///< The spellchecking engine
52 
53 	/// Words which the user has indicated should always be corrected
54 	std::map<std::string, std::string> auto_replace;
55 
56 	/// Words which the user has temporarily added to the dictionary
57 	std::set<std::string> auto_ignore;
58 
59 	/// Dictionaries available
60 	wxArrayString dictionary_lang_codes;
61 
62 	int word_start; ///< Start index of the current misspelled word
63 	int word_len;   ///< Length of the current misspelled word
64 
65 	wxTextCtrl *orig_word;    ///< The word being corrected
66 	wxTextCtrl *replace_word; ///< The replacement that will be used if "Replace" is clicked
67 	wxListBox *suggest_list;  ///< The list of suggested replacements
68 
69 	wxComboBox *language;      ///< The list of available languages
70 	wxButton *add_button;      ///< Add word to currently active dictionary
71 	wxButton *remove_button;   ///< Remove word from currently active dictionary
72 
73 	AssDialogue *start_line = nullptr;  ///< The first line checked
74 	AssDialogue *active_line = nullptr; ///< The most recently checked line
75 	bool has_looped = false;            ///< Has the search already looped from the end to beginning?
76 
77 	/// Find the next misspelled word and close the dialog if there are none
78 	/// @return Are there any more misspelled words?
79 	bool FindNext();
80 
81 	/// Check a single line for misspellings
82 	/// @param active_line Line to check
83 	/// @param start_pos Index in the line to start at
84 	/// @param[in,out] commit_id Commit id for coalescing autoreplace commits
85 	/// @return Was a misspelling found?
86 	bool CheckLine(AssDialogue *active_line, int start_pos, int *commit_id);
87 
88 	/// Set the current word to be corrected
89 	void SetWord(std::string const& word);
90 	/// Correct the currently selected word
91 	void Replace();
92 
93 	void OnChangeLanguage(wxCommandEvent&);
94 	void OnChangeSuggestion(wxCommandEvent&);
95 
96 	void OnReplace(wxCommandEvent&);
97 
98 public:
99 	DialogSpellChecker(agi::Context *context);
100 };
101 
DialogSpellChecker(agi::Context * context)102 DialogSpellChecker::DialogSpellChecker(agi::Context *context)
103 : wxDialog(context->parent, -1, _("Spell Checker"))
104 , context(context)
105 , spellchecker(SpellCheckerFactory::GetSpellChecker())
106 {
107 	SetIcon(GETICON(spellcheck_toolbutton_16));
108 
109 	wxSizer *main_sizer = new wxBoxSizer(wxVERTICAL);
110 
111 	auto current_word_sizer = new wxFlexGridSizer(2, 5, 5);
112 	main_sizer->Add(current_word_sizer, wxSizerFlags().Expand().Border(wxALL, 5));
113 
114 	wxSizer *bottom_sizer = new wxBoxSizer(wxHORIZONTAL);
115 	main_sizer->Add(bottom_sizer, wxSizerFlags().Expand().Border(~wxTOP & wxALL, 5));
116 
117 	wxSizer *bottom_left_sizer = new wxBoxSizer(wxVERTICAL);
118 	bottom_sizer->Add(bottom_left_sizer, wxSizerFlags().Expand().Border(wxRIGHT, 5));
119 
120 	wxSizer *actions_sizer = new wxBoxSizer(wxVERTICAL);
121 	bottom_sizer->Add(actions_sizer, wxSizerFlags().Expand());
122 
123 	// Misspelled word and currently selected correction
124 	current_word_sizer->AddGrowableCol(1, 1);
125 	current_word_sizer->Add(new wxStaticText(this, -1, _("Misspelled word:")), 0, wxALIGN_CENTER_VERTICAL);
126 	current_word_sizer->Add(orig_word = new wxTextCtrl(this, -1, "", wxDefaultPosition, wxDefaultSize, wxTE_READONLY), wxSizerFlags(1).Expand());
127 	current_word_sizer->Add(new wxStaticText(this, -1, _("Replace with:")), 0, wxALIGN_CENTER_VERTICAL);
128 	current_word_sizer->Add(replace_word = new wxTextCtrl(this, -1, ""), wxSizerFlags(1).Expand());
129 
130 	replace_word->Bind(wxEVT_TEXT, [=](wxCommandEvent&) {
131 		remove_button->Enable(spellchecker->CanRemoveWord(from_wx(replace_word->GetValue())));
132 	});
133 
134 	// List of suggested corrections
135 	suggest_list = new wxListBox(this, -1, wxDefaultPosition, wxSize(300, 150));
136 	suggest_list->Bind(wxEVT_LISTBOX, &DialogSpellChecker::OnChangeSuggestion, this);
137 	suggest_list->Bind(wxEVT_LISTBOX_DCLICK, &DialogSpellChecker::OnReplace, this);
138 	bottom_left_sizer->Add(suggest_list, wxSizerFlags(1).Expand());
139 
140 	// List of supported spellchecker languages
141 	{
142 		if (!spellchecker.get()) {
143 			wxMessageBox("No spellchecker available.", "Error", wxOK | wxICON_ERROR | wxCENTER);
144 			throw agi::UserCancelException("No spellchecker available");
145 		}
146 
147 		dictionary_lang_codes = to_wx(spellchecker->GetLanguageList());
148 		if (dictionary_lang_codes.empty()) {
149 			wxMessageBox("No spellchecker dictionaries available.", "Error", wxOK | wxICON_ERROR | wxCENTER);
150 			throw agi::UserCancelException("No spellchecker dictionaries available");
151 		}
152 
153 		wxArrayString language_names(dictionary_lang_codes);
154 		for (size_t i = 0; i < dictionary_lang_codes.size(); ++i) {
155 			if (const wxLanguageInfo *info = wxLocale::FindLanguageInfo(dictionary_lang_codes[i]))
156 				language_names[i] = info->Description;
157 		}
158 
159 		language = new wxComboBox(this, -1, "", wxDefaultPosition, wxDefaultSize, language_names, wxCB_DROPDOWN | wxCB_READONLY);
160 		wxString cur_lang = to_wx(OPT_GET("Tool/Spell Checker/Language")->GetString());
161 		int cur_lang_index = dictionary_lang_codes.Index(cur_lang);
162 		if (cur_lang_index == wxNOT_FOUND) cur_lang_index = dictionary_lang_codes.Index("en");
163 		if (cur_lang_index == wxNOT_FOUND) cur_lang_index = dictionary_lang_codes.Index("en_US");
164 		if (cur_lang_index == wxNOT_FOUND) cur_lang_index = 0;
165 		language->SetSelection(cur_lang_index);
166 		language->Bind(wxEVT_COMBOBOX, &DialogSpellChecker::OnChangeLanguage, this);
167 
168 		bottom_left_sizer->Add(language, wxSizerFlags().Expand().Border(wxTOP, 5));
169 	}
170 
171 	{
172 		wxSizerFlags button_flags = wxSizerFlags().Expand().Bottom().Border(wxBOTTOM, 5);
173 
174 		auto make_checkbox = [&](wxString const& text, const char *opt) {
175 			auto checkbox = new wxCheckBox(this, -1, text);
176 			actions_sizer->Add(checkbox, button_flags);
177 			checkbox->SetValue(OPT_GET(opt)->GetBool());
178 			checkbox->Bind(wxEVT_CHECKBOX,
179 				[=](wxCommandEvent &evt) { OPT_SET(opt)->SetBool(!!evt.GetInt()); });
180 		};
181 
182 		make_checkbox(_("&Skip Comments"), "Tool/Spell Checker/Skip Comments");
183 		make_checkbox(_("Ignore &UPPERCASE words"), "Tool/Spell Checker/Skip Uppercase");
184 
185 		wxButton *button;
186 
187 		actions_sizer->Add(button = new wxButton(this, -1, _("&Replace")), button_flags);
188 		button->Bind(wxEVT_BUTTON, &DialogSpellChecker::OnReplace, this);
189 
190 		actions_sizer->Add(button = new wxButton(this, -1, _("Replace &all")), button_flags);
191 		button->Bind(wxEVT_BUTTON, [=](wxCommandEvent&) {
192 			auto_replace[from_wx(orig_word->GetValue())] = from_wx(replace_word->GetValue());
193 			Replace();
194 			FindNext();
195 		});
196 
197 		actions_sizer->Add(button = new wxButton(this, -1, _("&Ignore")), button_flags);
198 		button->Bind(wxEVT_BUTTON, [=](wxCommandEvent&) { FindNext(); });
199 
200 		actions_sizer->Add(button = new wxButton(this, -1, _("Ignore a&ll")), button_flags);
201 		button->Bind(wxEVT_BUTTON, [=](wxCommandEvent&) {
202 			auto_ignore.insert(from_wx(orig_word->GetValue()));
203 			FindNext();
204 		});
205 
206 		actions_sizer->Add(add_button = new wxButton(this, -1, _("Add to &dictionary")), button_flags);
207 		add_button->Bind(wxEVT_BUTTON, [=](wxCommandEvent&) {
208 			spellchecker->AddWord(from_wx(orig_word->GetValue()));
209 			FindNext();
210 		});
211 
212 		actions_sizer->Add(remove_button = new wxButton(this, -1, _("Remove fro&m dictionary")), button_flags);
213 		remove_button->Bind(wxEVT_BUTTON, [=](wxCommandEvent&) {
214 			spellchecker->RemoveWord(from_wx(replace_word->GetValue()));
215 			SetWord(from_wx(orig_word->GetValue()));
216 		});
217 
218 		actions_sizer->Add(new HelpButton(this, "Spell Checker"), button_flags);
219 
220 		actions_sizer->Add(new wxButton(this, wxID_CANCEL), button_flags.Border(0));
221 	}
222 
223 	SetSizerAndFit(main_sizer);
224 	CenterOnParent();
225 
226 	if (FindNext())
227 		Show();
228 }
229 
OnReplace(wxCommandEvent &)230 void DialogSpellChecker::OnReplace(wxCommandEvent&) {
231 	Replace();
232 	FindNext();
233 }
234 
OnChangeLanguage(wxCommandEvent &)235 void DialogSpellChecker::OnChangeLanguage(wxCommandEvent&) {
236 	wxString code = dictionary_lang_codes[language->GetSelection()];
237 	OPT_SET("Tool/Spell Checker/Language")->SetString(from_wx(code));
238 
239 	FindNext();
240 }
241 
OnChangeSuggestion(wxCommandEvent &)242 void DialogSpellChecker::OnChangeSuggestion(wxCommandEvent&) {
243 	replace_word->SetValue(suggest_list->GetStringSelection());
244 }
245 
FindNext()246 bool DialogSpellChecker::FindNext() {
247 	AssDialogue *real_active_line = context->selectionController->GetActiveLine();
248 	// User has changed the active line; restart search from this position
249 	if (real_active_line != active_line) {
250 		active_line = real_active_line;
251 		has_looped = false;
252 		start_line = active_line;
253 	}
254 
255 	int start_pos = context->textSelectionController->GetInsertionPoint();
256 	int commit_id = -1;
257 
258 	if (CheckLine(active_line, start_pos, &commit_id))
259 		return true;
260 
261 	auto it = context->ass->iterator_to(*active_line);
262 
263 	// Note that it is deliberate that the start line is checked twice, as if
264 	// the cursor is past the first misspelled word in the current line, that
265 	// word should be hit last
266 	while(!has_looped || active_line != start_line) {
267 		// Wrap around to the beginning if we hit the end
268 		if (++it == context->ass->Events.end()) {
269 			it = context->ass->Events.begin();
270 			has_looped = true;
271 		}
272 
273 		active_line = &*it;
274 		if (CheckLine(active_line, 0, &commit_id))
275 			return true;
276 	}
277 
278 	if (IsShown()) {
279 		wxMessageBox(_("Aegisub has finished checking spelling of this script."), _("Spell checking complete."));
280 		Close();
281 	}
282 	else {
283 		wxMessageBox(_("Aegisub has found no spelling mistakes in this script."), _("Spell checking complete."));
284 		throw agi::UserCancelException("No spelling mistakes");
285 	}
286 
287 	return false;
288 }
289 
CheckLine(AssDialogue * active_line,int start_pos,int * commit_id)290 bool DialogSpellChecker::CheckLine(AssDialogue *active_line, int start_pos, int *commit_id) {
291 	if (active_line->Comment && OPT_GET("Tool/Spell Checker/Skip Comments")->GetBool()) return false;
292 
293 	std::string text = active_line->Text;
294 	auto tokens = agi::ass::TokenizeDialogueBody(text);
295 	agi::ass::SplitWords(text, tokens);
296 
297 	bool ignore_uppercase = OPT_GET("Tool/Spell Checker/Skip Uppercase")->GetBool();
298 
299 	word_start = 0;
300 	for (auto const& tok : tokens) {
301 		if (tok.type != agi::ass::DialogueTokenType::WORD || word_start < start_pos) {
302 			word_start += tok.length;
303 			continue;
304 		}
305 
306 		word_len = tok.length;
307 		std::string word = text.substr(word_start, word_len);
308 
309 		if (auto_ignore.count(word) || spellchecker->CheckWord(word) || (ignore_uppercase && word == boost::locale::to_upper(word))) {
310 			word_start += tok.length;
311 			continue;
312 		}
313 
314 		auto auto_rep = auto_replace.find(word);
315 		if (auto_rep == auto_replace.end()) {
316 #ifdef __WXGTK__
317 			// http://trac.wxwidgets.org/ticket/14369
318 			orig_word->Remove(0, -1);
319 			replace_word->Remove(0, -1);
320 #endif
321 
322 			context->selectionController->SetSelectionAndActive({ active_line }, active_line);
323 			SetWord(word);
324 			return true;
325 		}
326 
327 		text.replace(word_start, word_len, auto_rep->second);
328 		active_line->Text = text;
329 		*commit_id = context->ass->Commit(_("spell check replace"), AssFile::COMMIT_DIAG_TEXT, *commit_id);
330 		word_start += auto_rep->second.size();
331 	}
332 	return false;
333 }
334 
Replace()335 void DialogSpellChecker::Replace() {
336 	AssDialogue *active_line = context->selectionController->GetActiveLine();
337 
338 	// Only replace if the user hasn't changed the selection to something else
339 	if (to_wx(active_line->Text.get().substr(word_start, word_len)) == orig_word->GetValue()) {
340 		std::string text = active_line->Text;
341 		text.replace(word_start, word_len, from_wx(replace_word->GetValue()));
342 		active_line->Text = text;
343 		context->ass->Commit(_("spell check replace"), AssFile::COMMIT_DIAG_TEXT);
344 		context->textSelectionController->SetInsertionPoint(word_start + replace_word->GetValue().size());
345 	}
346 }
347 
SetWord(std::string const & word)348 void DialogSpellChecker::SetWord(std::string const& word) {
349 	orig_word->SetValue(to_wx(word));
350 
351 	wxArrayString suggestions = to_wx(spellchecker->GetSuggestions(word));
352 	replace_word->SetValue(suggestions.size() ? suggestions[0] : to_wx(word));
353 	suggest_list->Clear();
354 	suggest_list->Append(suggestions);
355 
356 	context->textSelectionController->SetSelection(word_start, word_start + word_len);
357 	context->textSelectionController->SetInsertionPoint(word_start + word_len);
358 
359 	add_button->Enable(spellchecker->CanAddWord(word));
360 }
361 }
362 
ShowSpellcheckerDialog(agi::Context * c)363 void ShowSpellcheckerDialog(agi::Context *c) {
364 	c->dialog->Show<DialogSpellChecker>(c);
365 }
366