cave-of-dreams/addons/godot_super-wakatime/godot_super-wakatime.gd

569 lines
18 KiB
GDScript3
Raw Permalink Normal View History

@tool
extends EditorPlugin
#------------------------------- SETUP -------------------------------
# Utilities
var Utils = preload("res://addons/godot_super-wakatime/utils.gd").new()
var DecompressorUtils = preload("res://addons/godot_super-wakatime/decompressor.gd").new()
# Paths, Urls
const PLUGIN_PATH: String = "res://addons/godot_super-wakatime"
const ZIP_PATH: String = "%s/wakatime.zip" % PLUGIN_PATH
const WAKATIME_URL_FMT: String = \
"https://github.com/wakatime/wakatime-cli/releases/download/v1.54.0/{wakatime_build}.zip"
const DECOMPERSSOR_URL_FMT: String = \
"https://github.com/ouch-org/ouch/releases/download/0.3.1/{ouch_build}"
# Names for menu
const API_MENU_ITEM: String = "Wakatime API key"
const CONFIG_MENU_ITEM: String = "Wakatime Config File"
# Directories to grab wakatime from
var wakatime_dir = null
var wakatime_cli = null
var decompressor_cli = null
var ApiKeyPrompt: PackedScene = preload("res://addons/godot_super-wakatime/api_key_prompt.tscn")
var Counter: PackedScene = preload("res://addons/godot_super-wakatime/counter.tscn")
# Set platform
var system_platform: String = Utils.set_platform()[0]
var system_architecture: String = Utils.set_platform()[1]
var debug: bool = false
# parity with kate-wakatime
const LOG_INTERVAL: int = 120000
var key_get_tries: int = 0
var counter_instance: Node
var current_time: String = "0 hrs, 0mins"
# #------------------------------- DIRECT PLUGIN FUNCTIONS -------------------------------
func _ready() -> void:
setup_plugin()
set_process(true)
func _exit_tree() -> void:
_disable_plugin()
set_process(false)
class DataSnapshot:
var file_path: String
var line_no: int
var cursor_pos: int
var lines: int
func get_coding_data(file: Script = null) -> DataSnapshot:
var snapshot = DataSnapshot.new()
if not file:
file = get_editor_interface().get_script_editor().get_current_script()
if not file:
return null
snapshot.file_path = ProjectSettings.globalize_path(file.resource_path)
var code_edit: CodeEdit = get_editor_interface().get_script_editor().get_current_editor().get_base_editor()
if code_edit is not CodeEdit:
return null
snapshot.line_no = code_edit.get_caret_line()
snapshot.cursor_pos = code_edit.get_caret_column()
snapshot.lines = code_edit.get_line_count()
return snapshot
func get_building_data(mouse_event: InputEventMouse, file_path: String = "") -> DataSnapshot:
"""Generate"""
var snapshot = DataSnapshot.new()
if file_path == "":
if EditorInterface.get_edited_scene_root() == null:
return null
var file = EditorInterface.get_edited_scene_root().scene_file_path
if not file:
return null
snapshot.file_path = ProjectSettings.globalize_path(file)
else:
snapshot.file_path = file_path
snapshot.line_no = int(roundf(mouse_event.position.x))
snapshot.cursor_pos = int(roundf(mouse_event.position.y))
# no line numbers in a screen lol
snapshot.lines = 0
return snapshot
var last_tick_frame: int = 0
var last_mouse_event: InputEventMouse
var moved_on_edit: bool = false
func _input(event: InputEvent) -> void:
"""Handle all input events"""
if Time.get_ticks_msec() - last_tick_frame > LOG_INTERVAL:
if tab_open == "Script":
if event is InputEventKey:
var key_event = event as InputEventKey
if (key_event.keycode == KEY_UP || key_event.keycode == KEY_DOWN
|| key_event.keycode == KEY_LEFT || key_event.keycode == KEY_RIGHT
|| key_event.keycode == KEY_ALT || key_event.keycode == KEY_SHIFT):
return
var snapshot = get_coding_data()
if snapshot == null:
return
send_heartbeat(snapshot.file_path, "coding", snapshot.line_no, snapshot.cursor_pos, snapshot.lines, false)
last_tick_frame = Time.get_ticks_msec()
elif tab_open == "2D" || tab_open == "3D":
if event is InputEventMouseButton: # we dont really care if people are typing in node editor
var snapshot = get_building_data(event)
if snapshot == null:
return
send_heartbeat(snapshot.file_path, "building", snapshot.line_no, snapshot.cursor_pos, snapshot.lines, false)
last_tick_frame = Time.get_ticks_msec()
# store the mouse data for scene saved
if tab_open == "2D" || tab_open == "3D":
if event is InputEventMouse:
last_mouse_event = event
moved_on_edit = true
# 2D, 3D, Script, Game, or AssetLib
var tab_open: String = "2D"
func _main_screen_changed(tab_name: String):
"""Update the curent tab so we can know where our input events are going to"""
tab_open = tab_name
# no cooldown for writes
func _scene_saved(file_path: String):
"""Handle heartbeats to be sent on scene save"""
if last_mouse_event == null:
return
if !moved_on_edit:
return
moved_on_edit = false
var snapshot = get_building_data(last_mouse_event)
if snapshot == null:
return
send_heartbeat(snapshot.file_path, "building", snapshot.line_no, snapshot.cursor_pos, snapshot.lines, true)
func _resource_saved(resource: Resource):
"""Handle heartbeats to be sent on file save"""
if not resource is GDScript:
return
var snapshot = get_coding_data(resource)
if snapshot == null:
return
send_heartbeat(snapshot.file_path, "coding", snapshot.line_no, snapshot.cursor_pos, snapshot.lines, true)
func setup_plugin() -> void:
"""Setup Wakatime plugin, download dependencies if needed, initialize menu"""
Utils.plugin_print("Setting up %s" % get_user_agent())
check_dependencies()
main_screen_changed.connect(_main_screen_changed)
scene_saved.connect(_scene_saved)
resource_saved.connect(_resource_saved)
# Grab API key if needed
var api_key = get_api_key()
if api_key == null:
request_api_key()
await get_tree().process_frame
# Add menu buttons
add_tool_menu_item(API_MENU_ITEM, request_api_key)
add_tool_menu_item(CONFIG_MENU_ITEM, open_config)
counter_instance = Counter.instantiate()
add_control_to_bottom_panel(counter_instance, current_time)
func _disable_plugin() -> void:
"""Cleanup after disabling plugin"""
# Remove items from menu
remove_tool_menu_item(API_MENU_ITEM)
remove_tool_menu_item(CONFIG_MENU_ITEM)
remove_control_from_bottom_panel(counter_instance)
func send_heartbeat(filepath: String, catagory: String, line_num: int, cursor_pos: int, lines: int, is_write: bool) -> void:
"""Send Wakatimde heartbeat for the specified file"""
if not Utils.wakatime_cli_exists(get_waka_cli()):
print("Wakatime not installed!")
return
# Check Wakatime API key
var api_key = get_api_key()
if api_key == null:
Utils.plugin_print("Failed to get Wakatime API key. Are you sure it's correct?")
if (key_get_tries < 3):
request_api_key()
key_get_tries += 1
else:
Utils.plugin_print("If this keep occuring, please create a file: ~/.wakatime.cfg\n
initialize it with:\n[settings]\napi_key={your_key}")
return
# Append all informations as Wakatime CLI arguments
var cmd: Array[Variant] = ["--entity", filepath, "--key", api_key]
if is_write:
cmd.append("--write")
cmd.append_array(["--alternate-project", ProjectSettings.get("application/config/name")])
cmd.append_array(["--time", str(Time.get_unix_time_from_system())])
cmd.append_array(["--lineno", str(line_num)])
cmd.append_array(["--cursorpos", str(cursor_pos)])
cmd.append_array(["--lines-in-file", str(lines)])
cmd.append_array(["--plugin", get_user_agent()])
cmd.append_array(["--category", catagory])
# Send heartbeat using Wakatime CLI
var cmd_callable = Callable(self, "_handle_heartbeat").bind(cmd)
WorkerThreadPool.add_task(cmd_callable)
func _find_code_edit_recursive(node: Node) -> CodeEdit:
"""Find recursively code editor in a node"""
# If node is already a code editor, return it
if node is CodeEdit:
return node
# Try to find it in every child of a given node
for child in node.get_children():
var editor = _find_code_edit_recursive(child)
if editor:
return editor
return null
func _handle_heartbeat(cmd_arguments) -> void:
"""Handle sending the heartbeat"""
# Get Wakatime CLI and try to send a heartbeat
if wakatime_cli == null:
wakatime_cli = Utils.get_waka_cli()
var output: Array[Variant] = []
var exit_code: int = OS.execute(wakatime_cli, cmd_arguments, output, true)
update_today_time(wakatime_cli)
# Inform about success or errors if user is in debug
if debug:
if exit_code == -1:
Utils.plugin_print("Failed to send heartbeat: %s" % output)
else:
Utils.plugin_print("Heartbeat sent: %s" % output)
func update_today_time(wakatime_cli) -> void:
"""Update today's time in menu"""
var output: Array[Variant] = []
# I would use --plugin "godot/4.6.2 Godot_Super-Wakatime/2.0.1" but it doesn't seem to do anything with --today
# Get today's time from Wakatime CLI
var exit_code: int = OS.execute(wakatime_cli, ["--today"], output, true)
# Convert it and combine different categories into
if exit_code == 0:
current_time = output[0]
else:
current_time = "Wakatime"
#print(current_time)
call_deferred("_update_panel_label", current_time, output[0])
func _update_panel_label(label: String, content: String):
"""Update bottom panel name that shows time"""
# If counter exists and it has a label, update both the label and panel's name
if counter_instance and counter_instance.get_node("HBoxContainer/Label"):
counter_instance.get_node("HBoxContainer/Label").text = content
# Workaround to rename panel
remove_control_from_bottom_panel(counter_instance)
add_control_to_bottom_panel(counter_instance, label)
#------------------------------- FILE FUNCTIONS -------------------------------
func open_config() -> void:
"""Open wakatime config file"""
OS.shell_open(Utils.config_filepath(system_platform, PLUGIN_PATH))
func get_waka_dir() -> String:
"""Search for and return wakatime directory"""
if wakatime_dir == null:
wakatime_dir = "%s/.wakatime" % Utils.home_directory(system_platform, PLUGIN_PATH)
return wakatime_dir
func get_waka_cli() -> String:
"""Get wakatime_cli file"""
if wakatime_cli == null:
var build = Utils.get_waka_build(system_platform, system_architecture)
var ext: String = ".exe" if system_platform == "windows" else ''
wakatime_cli = "%s/%s%s" % [get_waka_dir(), build, ext]
return wakatime_cli
func check_dependencies() -> void:
"""Make sure all dependencies exist"""
if !Utils.wakatime_cli_exists(get_waka_cli()):
download_wakatime()
if !DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH):
download_decompressor()
func download_wakatime() -> void:
"""Download wakatime cli"""
Utils.plugin_print("Downloading Wakatime CLI...")
var url: String = WAKATIME_URL_FMT.format({"wakatime_build":
Utils.get_waka_build(system_platform, system_architecture)})
# Try downloading wakatime
var http = HTTPRequest.new()
http.download_file = ZIP_PATH
http.connect("request_completed", Callable(self, "_wakatime_download_completed"))
add_child(http)
# Handle errors
var status = http.request(url)
if status != OK:
Utils.plugin_print_err("Failed to start downloading Wakatime [Error: %s]" % status)
_disable_plugin()
func download_decompressor() -> void:
"""Download ouch decompressor"""
Utils.plugin_print("Downloading Ouch! decompression library...")
var url: String = DECOMPERSSOR_URL_FMT.format({"ouch_build":
Utils.get_ouch_build(system_platform)})
if system_platform == "windows":
url += ".exe"
# Try to download ouch
var http = HTTPRequest.new()
http.download_file = DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH)
http.connect("request_completed", Callable(self, "_decompressor_download_finished"))
add_child(http)
# Handle errors
var status = http.request(url)
if status != OK:
_disable_plugin()
Utils.plugin_print_err("Failed to start downloading Ouch! library [Error: %s]" % status)
func _wakatime_download_completed(result, status, headers, body) -> void:
"""Finish downloading wakatime, handle errors"""
if result != HTTPRequest.RESULT_SUCCESS:
Utils.plugin_print_err("Error while downloading Wakatime CLI")
_disable_plugin()
return
Utils.plugin_print("Wakatime CLI has been installed succesfully! Located at %s" % ZIP_PATH)
extract_files(ZIP_PATH, get_waka_dir())
func _decompressor_download_finished(result, status, headers, body) -> void:
"""Handle errors and finishi decompressor download"""
# Error while downloading
if result != HTTPRequest.RESULT_SUCCESS:
Utils.plugin_print_err("Error while downloading Ouch! library")
_disable_plugin()
return
# Error while saving
if !DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH):
Utils.plugin_print_err("Failed to save Ouch! library")
_disable_plugin()
return
# Save decompressor path, give write permissions to it
var decompressor: String = \
ProjectSettings.globalize_path(DecompressorUtils.decompressor_cli(decompressor_cli,
system_platform, PLUGIN_PATH))
if system_platform == "linux" or system_platform == "darwin":
OS.execute("chmod", ["+x", decompressor], [], true)
# Extract files, allowing usage of Ouch!
Utils.plugin_print("Ouch! has been installed succesfully! Located at %s" % \
DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH))
extract_files(ZIP_PATH, get_waka_dir())
func extract_files(source: String, destination: String) -> void:
"""Extract downloaded Wakatime zip"""
# If decompression library and wakatime zip folder don't exist, return
if not DecompressorUtils.lib_exists(decompressor_cli, system_platform,
PLUGIN_PATH) and not Utils.wakatime_zip_exists(ZIP_PATH):
return
# Get paths as global
Utils.plugin_print("Extracting Wakatime...")
var decompressor: String
if system_platform == "windows":
decompressor = ProjectSettings.globalize_path(
DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH))
else:
decompressor = ProjectSettings.globalize_path("res://" +
DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH))
var src: String = ProjectSettings.globalize_path(source)
var dest: String = ProjectSettings.globalize_path(destination)
# Execute Ouch! decompression command, catch errors
var errors: Array[Variant] = []
var args: Array[String] = ["--yes", "decompress", src, "--dir", dest]
var error: int = OS.execute(decompressor, args, errors, true)
if error:
Utils.plugin_print(errors)
_disable_plugin()
return
# Results
if Utils.wakatime_cli_exists(get_waka_cli()):
Utils.plugin_print("Wakatime CLI installed (path: %s)" % get_waka_cli())
else:
Utils.plugin_print_err("Installation of Wakatime failed")
_disable_plugin()
# Remove leftover files
clean_files()
func clean_files():
"""Delete files that aren't needed anymore"""
if Utils.wakatime_zip_exists(ZIP_PATH):
delete_file(ZIP_PATH)
if DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH):
delete_file(ZIP_PATH)
func delete_file(path: String) -> void:
"""Delete file at specified path"""
var dir: DirAccess = DirAccess.open("res://")
var status: int = dir.remove(path)
if status != OK:
Utils.plugin_print_err("Failed to clean unnecesary file at %s" % path)
else:
Utils.plugin_print("Clean unncecesary file at %s" % path)
#------------------------------- API KEY FUNCTIONS -------------------------------
func get_api_key():
"""Get wakatime api key"""
var result = []
# Handle errors while getting the key
var err = OS.execute(get_waka_cli(), ["--config-read", "api_key"], result)
if err == -1:
return null
# Trim API key from whitespaces and return it
var key = result[0].strip_edges()
if key.is_empty():
return null
return key
func request_api_key() -> void:
"""Request Wakatime API key from the user"""
# Prepare prompt
var prompt = ApiKeyPrompt.instantiate()
_set_api_key(prompt, get_api_key())
_register_api_key_signals(prompt)
# Show prompt and hide it on request
add_child(prompt)
prompt.popup_centered()
await prompt.popup_hide
prompt.queue_free()
func _set_api_key(prompt: PopupPanel, api_key) -> void:
"""Set API key from prompt"""
# Safeguard against empty key
if api_key == null:
api_key = ''
# Set correct text, to show API key
var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit")
edit_field.text = api_key
func _register_api_key_signals(prompt: PopupPanel) -> void:
"""Connect all signals related to API key popup"""
# Get all Nodes
var show_button: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/ShowButton")
var save_button: Node = prompt.get_node("VBoxContainer/HBoxContainerBottom/SubmitButton")
# Connect them to press events
show_button.connect("pressed", Callable(self, "_on_toggle_key_text").bind(prompt))
save_button.connect("pressed", Callable(self, "_on_save_key").bind(prompt))
prompt.connect("popup_hide", Callable(self, "_on_popup_hide").bind(prompt))
func _on_popup_hide(prompt: PopupPanel):
"""Close the popup window when user wants to hide it"""
prompt.queue_free()
func _on_toggle_key_text(prompt: PopupPanel) -> void:
"""Handle hiding and showing API key"""
# Get nodes
var show_button: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/ShowButton")
var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit")
# Set the correct text and hide field if needed
edit_field.secret = not edit_field.secret
show_button.text = "Show" if edit_field.secret else "Hide"
func _on_save_key(prompt: PopupPanel) -> void:
"""Handle entering API key"""
# Get text field node and api key that's entered
var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit")
var api_key = edit_field.text.strip_edges()
# Try to set api key for wakatime and handle errors
var err: int = OS.execute(get_waka_cli(), ["--config-write", "api-key=%s" % api_key])
if err == -1:
Utils.plugin_print("Failed to save API key")
prompt.visible = false
#------------------------------- PLUGIN INFORMATIONS -------------------------------
func get_user_agent() -> String:
"""Get user agent identifier"""
var os_name = OS.get_name().to_lower()
return "godot/%s %s/%s" % [
get_engine_version(),
_get_plugin_name(),
_get_plugin_version()
]
func _get_plugin_name() -> String:
"""Get name of the plugin"""
return "Godot_Super-Wakatime"
func _get_plugin_version() -> String:
"""Get version of the plugin"""
if get_plugin_version() == "":
return "unknown"
return get_plugin_version()
func get_engine_version() -> String:
"""Get verison of currently used engine"""
return "%s.%s.%s" % [Engine.get_version_info()["major"], Engine.get_version_info()["minor"],
Engine.get_version_info()["patch"]]