Created LanguageTool Plugin

This commit is contained in:
jonathan
2025-08-14 06:30:17 +02:00
parent 8676bbb2f6
commit d5fa2f88be
21 changed files with 1140 additions and 0 deletions
@@ -0,0 +1,41 @@
@tool
extends Node
class_name LanguageToolCorrectionOverlay
const CORRECTION_OVERLAY = preload("res://addons/languagetool/scenes/correction_overlay.tscn")
var instantiatedOverlay: LanguageToolCorrectionOverlayReferences
func _enter_tree() -> void:
instantiatedOverlay = CORRECTION_OVERLAY.instantiate()
add_child(instantiatedOverlay)
hideOverlay()
func _ready():
instantiatedOverlay.close_button.pressed.connect(hideOverlay)
func _exit_tree() -> void:
pass
func hideOverlay():
instantiatedOverlay.hide()
func showOverlay(position:Vector2, width: float, _match:LanguageToolApiWrapper.LanguageToolCheckResponse.Match, replacement_clicked : Callable):
#print(instantiatedOverlay.test)
instantiatedOverlay.show()
instantiatedOverlay.global_position = position
instantiatedOverlay.size = Vector2(0,0)
instantiatedOverlay.custom_minimum_size = Vector2(width, 0)
instantiatedOverlay.category_label.text = _match.rule.category.name
instantiatedOverlay.description_label.text = _match.message
for c in instantiatedOverlay.replacements.get_children():
c.free()
for r in _match.replacements:
var replacementButton = Button.new()
replacementButton.text = r
replacementButton.size_flags_horizontal = Control.SIZE_EXPAND_FILL
replacementButton.pressed.connect(func():replacement_clicked.call(r))
instantiatedOverlay.replacements.add_child(replacementButton)
@@ -0,0 +1 @@
uid://ct2t8rr000prq
@@ -0,0 +1,9 @@
@tool
extends VBoxContainer
class_name LanguageToolCorrectionOverlayReferences
@onready var category_label: Label = $MarginContainer/PanelContainer/MarginContainer/VBoxContainer/HBoxContainer/CategoryLabel
@onready var description_label: Label = $MarginContainer/PanelContainer/MarginContainer/VBoxContainer/DescriptionLabel
@onready var replacements: HFlowContainer = $MarginContainer/PanelContainer/MarginContainer/VBoxContainer/Replacements
@onready var close_button: Button = $MarginContainer/PanelContainer/MarginContainer/VBoxContainer/HBoxContainer/CloseButton
@@ -0,0 +1 @@
uid://dkyiuvuc2w2xc
@@ -0,0 +1,32 @@
extends SyntaxHighlighter
class_name LanguageToolErrorSyntaxHighlighter
var check:LanguageToolApiWrapper.LanguageToolCheckResponse
func _init(check:LanguageToolApiWrapper.LanguageToolCheckResponse):
self.check = check
func _get_line_syntax_highlighting(line: int) -> Dictionary:
var normalColor = EditorInterface.get_base_control().get_theme_color("font_color", "Editor")
var errorColor = EditorInterface.get_base_control().get_theme_color("error_color", "Editor")
var warningColor = EditorInterface.get_base_control().get_theme_color("warning_color", "Editor")
var successColor = EditorInterface.get_base_control().get_theme_color("success_color", "Editor")
var retval = {}
for m:LanguageToolApiWrapper.LanguageToolCheckResponse.Match in check.matches:
var row_column = LanguageToolUtils.offset_to_row_column(m.offset,get_text_edit().text)
if row_column[0] != line:
continue
match m.rule.category.id:
"GRAMMAR":
retval[row_column[1]] = {"color":warningColor}
"TYPOS":
retval[row_column[1]] = {"color":errorColor}
_:
retval[row_column[1]] = {"color":successColor}
retval[row_column[1]+m.length] = {"color":normalColor}
return retval
@@ -0,0 +1 @@
uid://csxcr0bsetagc
@@ -0,0 +1,112 @@
extends Node
class_name LanguagetToolPlugin
var api: LanguageToolApiWrapper
var overlay: LanguageToolCorrectionOverlay
var checkDict: Dictionary[String,LanguageToolApiWrapper.LanguageToolCheckResponse] = {}
func _enter_tree() -> void:
api = LanguageToolApiWrapper.new()
add_child(api)
overlay = LanguageToolCorrectionOverlay.new()
add_child(overlay)
func _exit_tree() -> void:
pass
func check_new_inspector():
overlay.hideOverlay()
var textEdits: Array[TextEdit] = _find_multiline_text_edits()
for te: TextEdit in textEdits:
te.text_changed.connect(func():_on_text_changed(te))
te.focus_exited.connect(func():_on_focus_lost(te))
te.caret_changed.connect(func():_on_caret_changed(te))
_check_text(te)
_mark_errors_in_text(te)
func _on_text_changed(textEdit: TextEdit):
_mark_errors_in_text(textEdit)
overlay.hideOverlay()
func _on_focus_lost(textEdit: TextEdit):
_check_text(textEdit)
_mark_errors_in_text(textEdit)
func _on_caret_changed(textEdit: TextEdit):
if(!checkDict.has(textEdit.text)):
return
var check: LanguageToolApiWrapper.LanguageToolCheckResponse = checkDict[textEdit.text]
# find match at caret
var caret_offset:int = LanguageToolUtils.row_column_to_offset(textEdit.get_caret_line(), textEdit.get_caret_column(),textEdit.text)
var _match:LanguageToolApiWrapper.LanguageToolCheckResponse.Match = null
for m in check.matches:
if m.offset <= caret_offset and m.offset + m.length >= caret_offset:
_match = m
break
if _match != null:
var edit_global_rect = textEdit.get_global_rect()
overlay.showOverlay(
edit_global_rect.position + Vector2(0,edit_global_rect.size.y),
edit_global_rect.size.x,
_match,
func(newText):_apply_text_change(textEdit,newText,_match))
else:
overlay.hideOverlay()
pass
func _check_text(textEdit: TextEdit):
if textEdit.text == "":
return
if checkDict.has(textEdit.text):
return
var response = api.check(textEdit.text)
checkDict[textEdit.text] = response
func _mark_errors_in_text(textEdit: TextEdit):
if(!checkDict.has(textEdit.text)):
textEdit.syntax_highlighter=null
return
var check: LanguageToolApiWrapper.LanguageToolCheckResponse = checkDict[textEdit.text]
textEdit.syntax_highlighter = LanguageToolErrorSyntaxHighlighter.new(check)
func _apply_text_change(textEdit:TextEdit, newText: String, _match:LanguageToolApiWrapper.LanguageToolCheckResponse.Match):
var oldText = textEdit.text
var removedOldWord = oldText.erase(_match.offset,_match.length)
var newWordInserted = removedOldWord.insert(_match.offset,newText)
textEdit.text = newWordInserted
textEdit.text_changed.emit()
overlay.hideOverlay()
_check_text(textEdit)
_mark_errors_in_text(textEdit)
func _find_multiline_text_edits()->Array[TextEdit]:
var multilinteTexts:Array[Node] = _find_recursive(
EditorInterface.get_inspector().get_child(0).get_child(2),
"EditorPropertyMultilineText");
var textEditors:Array[TextEdit]
textEditors.assign( multilinteTexts.map(func(c):return c.get_child(0).get_child(0) as TextEdit))
return textEditors
func _find_recursive(node: Node, type: Variant) -> Array[Node]:
if type is String:
if node.get_class() == type:
return [node]
elif is_instance_of(node, type):
return [node]
var retval: Array[Node] = []
for child in node.get_children():
retval.append_array(_find_recursive(child, type))
return retval
@@ -0,0 +1 @@
uid://bi8yv26eglkso
@@ -0,0 +1,32 @@
@tool
extends Object
class_name LanguageToolUtils
static func offset_to_row_column(offset:int, text:String)->Vector2i:
var row:int = 0
var column:int = 0
if offset > text.length():
return Vector2i(-1,-1)
for i in offset:
if text[i] == "\n":
row+=1
column = 0
else:
column+=1
return Vector2i(row, column)
static func row_column_to_offset(row:int, column:int, text:String) -> int:
var current_row:int = 0
var current_column:int = 0
for i in text.length():
if current_row == row and current_column == column:
return i
if text[i] == "\n":
current_row += 1
current_column = 0
else:
current_column += 1
return -1
@@ -0,0 +1 @@
uid://q01v4f8pfgfe
@@ -0,0 +1,211 @@
@tool
class_name LanguageToolApiWrapper
extends Node
const BASE_URL := "https://api.languagetoolplus.com/v2"
func _make_request(endpoint: String, method: HTTPClient.Method = HTTPClient.METHOD_GET, data: Dictionary = {}, headers: Dictionary = {}):
var url = BASE_URL + endpoint
var scheme_split = url.split("://")
var scheme = scheme_split[0]
var rest = scheme_split[1]
var host_and_path = rest.split("/", false, 1)
var host = host_and_path[0]
var path = "/" + host_and_path[1] if host_and_path.size() > 1 else "/"
var port = 443 if scheme == "https" else 80
var client = HTTPClient.new()
var tlsOptions: TLSOptions = (TLSOptions.client() if scheme == "https" else null)
var err = client.connect_to_host(host, port, tlsOptions)
if err != OK:
push_error("Failed to connect to host: " + str(err))
return null
while client.get_status() in [HTTPClient.STATUS_CONNECTING, HTTPClient.STATUS_RESOLVING]:
client.poll()
OS.delay_msec(10)
var header_array = []
for k in headers.keys():
header_array.append(str(k) + ": " + str(headers[k]))
var body = ""
if method == HTTPClient.METHOD_POST:
body = ""
if data.size() > 0:
body = client.query_string_from_dict(data)
header_array.append("Content-Type: application/x-www-form-urlencoded")
header_array.append("Content-Length: " + str(body.length()))
client.request(HTTPClient.METHOD_POST, path, header_array, body)
else:
if data.size() > 0:
path += "?" + client.query_string_from_dict(data)
client.request(HTTPClient.METHOD_GET, path, header_array)
while client.get_status() == HTTPClient.STATUS_REQUESTING:
client.poll()
OS.delay_msec(10)
var response = ""
while client.get_status() == HTTPClient.STATUS_BODY or client.has_response():
client.poll()
var chunk = client.read_response_body_chunk()
if chunk.size() == 0:
break
response += chunk.get_string_from_utf8()
OS.delay_msec(10)
var resp_code = client.get_response_code()
if resp_code != 200:
push_error("HTTP error: " + str(resp_code) + "\\n" + response)
return null
var json = JSON.new()
var json_err = json.parse(response)
if json_err != OK:
push_error("JSON parse error: " + str(json_err) + "\\n" + response)
return null
return json.get_data()
func check(text: String, language: String = "auto", opts: Dictionary = {}) -> LanguageToolCheckResponse:
var data = {
"text": text,
"language": language
}
for k in opts.keys():
data[k] = opts[k]
print("Checking text: "+text)
return LanguageToolCheckResponse.new(_make_request("/check", HTTPClient.METHOD_POST, data))
func get_languages():
return _make_request("/languages", HTTPClient.METHOD_GET)
func list_words(username: String, apiKey: String, offset: int = 0, limit: int = 10, dicts: String = ""):
var data = {
"username": username,
"apiKey": apiKey,
"offset": offset,
"limit": limit
}
if dicts != "":
data["dicts"] = dicts
return _make_request("/words", HTTPClient.METHOD_GET, data)
func add_word(word: String, username: String, apiKey: String, dict: String = ""):
var data = {
"word": word,
"username": username,
"apiKey": apiKey
}
if dict != "":
data["dict"] = dict
return _make_request("/words/add", HTTPClient.METHOD_POST, data)
func delete_word(word: String, username: String, apiKey: String, dict: String = ""):
var data = {
"word": word,
"username": username,
"apiKey": apiKey
}
if dict != "":
data["dict"] = dict
return _make_request("/words/delete", HTTPClient.METHOD_POST, data)
static func percent_encode(text: String) -> String:
return text.uri_encode()
class LanguageToolCheckResponse:
# Software info
var software_name: String
var software_version: String
var software_build_date: String
var software_api_version: int
var software_status: String = ""
var software_premium: bool = false
# Language info
var language_name: String
var language_code: String
var detected_language_name: String
var detected_language_code: String
# Match structure
class Match:
var message: String
var short_message: String = ""
var offset: int
var length: int
var replacements: Array[String] = []
var context_text: String
var context_offset: int
var context_length: int
var sentence: String
class Rule:
var id: String
var sub_id: String = ""
var description: String
var urls: Array[String] = []
var issue_type: String = ""
class Category:
var id: String
var name: String
var category: Category
var rule: Rule
var matches: Array[Match] = []
func _init(response: Variant) -> void:
# Parse software
var sw = response.software if response.has("software") else {}
software_name = sw.name if sw.has("name") else ""
software_version = sw.version if sw.has("version") else ""
software_build_date = sw.buildDate if sw.has("buildDate") else ""
software_api_version = sw.apiVersion if sw.has("apiVersion") else 0
software_status = sw.status if sw.has("status") else ""
software_premium = sw.premium if sw.has("premium") else false
# Parse language
var lang = response.language if response.has("language") else {}
language_name = lang.name if lang.has("name") else ""
language_code = lang.code if lang.has("code") else ""
var det_lang = lang.detectedLanguage if lang.has("detectedLanguage") else {}
detected_language_name = det_lang.name if det_lang.has("name") else ""
detected_language_code = det_lang.code if det_lang.has("code") else ""
# Parse matches
matches = []
var matches_arr = response.matches if response.has("matches") else []
for m in matches_arr:
var _match = Match.new()
_match.message = m.message if m.has("message") else ""
_match.short_message = m.shortMessage if m.has("shortMessage") else ""
_match.offset = m.offset if m.has("offset") else 0
_match.length = m.length if m.has("length") else 0
#_match.replacements = []
var replacements_arr = m.replacements if m.has("replacements") else []
for r in replacements_arr:
_match.replacements.append(r.value if r.has("value") else "")
var ctx = m.context if m.has("context") else {}
_match.context_text = ctx.text if ctx.has("text") else ""
_match.context_offset = ctx.offset if ctx.has("offset") else 0
_match.context_length = ctx.length if ctx.has("length") else 0
_match.sentence = m.sentence if m.has("sentence") else ""
var rule_dict = m.rule if m.has("rule") else {}
var rule = Match.Rule.new()
rule.id = rule_dict.id if rule_dict.has("id") else ""
rule.sub_id = rule_dict.subId if rule_dict.has("subId") else ""
rule.description = rule_dict.description if rule_dict.has("description") else ""
#rule.urls = []
var urls_arr = rule_dict.urls if rule_dict.has("urls") else []
for u in urls_arr:
rule.urls.append(u.value if u.has("value") else "")
rule.issue_type = rule_dict.issueType if rule_dict.has("issueType") else ""
var cat = rule_dict.category if rule_dict.has("category") else {}
var category = Match.Rule.Category.new()
category.id = cat.id if cat.has("id") else ""
category.name = cat.name if cat.has("name") else ""
rule.category = category
_match.rule = rule
matches.append(_match)
@@ -0,0 +1 @@
uid://bkyd022t8ugkw