5 #------------------------------- SETUP -------------------------------
7 var Utils = preload("res://addons/godot_super-wakatime/utils.gd").new()
8 var DecompressorUtils = preload("res://addons/godot_super-wakatime/decompressor.gd").new()
11 const HeartBeat = preload("res://addons/godot_super-wakatime/heartbeat.gd")
12 var last_heartbeat = HeartBeat.new()
15 const PLUGIN_PATH: String = "res://addons/godot_super-wakatime"
16 const ZIP_PATH: String = "%s/wakatime.zip" % PLUGIN_PATH
18 const WAKATIME_URL_FMT: String = \
19 "https://github.com/wakatime/wakatime-cli/releases/download/v1.54.0/{wakatime_build}.zip"
20 const DECOMPERSSOR_URL_FMT: String = \
21 "https://github.com/ouch-org/ouch/releases/download/0.3.1/{ouch_build}"
24 const API_MENU_ITEM: String = "Wakatime API key"
25 const CONFIG_MENU_ITEM: String = "Wakatime Config File"
27 # Directories to grab wakatime from
28 var wakatime_dir = null
29 var wakatime_cli = null
30 var decompressor_cli = null
32 var ApiKeyPrompt: PackedScene = preload("res://addons/godot_super-wakatime/api_key_prompt.tscn")
33 var Counter: PackedScene = preload("res://addons/godot_super-wakatime/counter.tscn")
36 var system_platform: String = Utils.set_platform()[0]
37 var system_architecture: String = Utils.set_platform()[1]
39 var debug: bool = false
40 var last_scene_path: String = ''
41 var last_file_path: String = ''
42 var last_time: int = 0
43 var previous_state: String = ''
44 const LOG_INTERVAL: int = 60000
45 var scene_mode: bool = false
47 var key_get_tries: int = 0
48 var counter_instance: Node
49 var current_time: String = "0 hrs, 0mins"
52 # #------------------------------- DIRECT PLUGIN FUNCTIONS -------------------------------
53 func _ready() -> void:
57 func _exit_tree() -> void:
62 func _physics_process(delta: float) -> void:
63 """Process plugin changes over time"""
64 # Every 1000 frames check for updates
65 if Engine.get_physics_frames() % 1000 == 0:
66 # Check for scene change
67 var scene_root = get_editor_interface().get_edited_scene_root()
69 var current_scene_path = _get_current_scene()
71 # If currently used scene is different thatn 1000 frames ago, log activity
72 if current_scene_path != last_scene_path:
73 last_scene_path = current_scene_path
74 handle_activity_scene(current_scene_path, false, true)
77 # Check for scene updates
78 var current_scene = EditorInterface.get_edited_scene_root()
80 var state = generate_scene_state(current_scene)
81 # If current state is different than the previous one, handle activity
82 if state != previous_state:
83 previous_state = state
84 handle_activity_scene(current_scene_path, true)
85 # Otherwise just keep scene the same
87 previous_state = state
91 func generate_scene_state(node: Node) -> String:
92 """Generate a scene state identifier"""
93 var state = str(node.get_instance_id())
94 for child in node.get_children():
95 state += str(child.get_instance_id())
98 func _input(event: InputEvent) -> void:
99 """Handle all input events"""
101 if event is InputEventKey:
102 var file = get_current_file()
104 handle_activity(ProjectSettings.globalize_path(file.resource_path))
105 # Mouse button events
106 elif event is InputEventMouse and event.is_pressed():
107 var file = _get_current_scene()
108 if file != '' and file:
109 handle_activity_scene(file)
111 func setup_plugin() -> void:
112 """Setup Wakatime plugin, download dependencies if needed, initialize menu"""
113 Utils.plugin_print("Setting up %s" % get_user_agent())
116 # Grab API key if needed
117 var api_key = get_api_key()
120 await get_tree().process_frame
123 add_tool_menu_item(API_MENU_ITEM, request_api_key)
124 add_tool_menu_item(CONFIG_MENU_ITEM, open_config)
126 counter_instance = Counter.instantiate()
127 add_control_to_bottom_panel(counter_instance, current_time)
129 # Connect code editor signals
130 var script_editor: ScriptEditor = get_editor_interface().get_script_editor()
131 script_editor.call_deferred("connect", "editor_script_changed", Callable(self,
132 "_on_script_changed"))
134 func _disable_plugin() -> void:
135 """Cleanup after disabling plugin"""
136 # Remove items from menu
137 remove_tool_menu_item(API_MENU_ITEM)
138 remove_tool_menu_item(CONFIG_MENU_ITEM)
140 remove_control_from_bottom_panel(counter_instance)
142 # Disconnect script editor tracking
143 var script_editor: ScriptEditor = get_editor_interface().get_script_editor()
144 if script_editor.is_connected("editor_script_changed", Callable(self,
145 "_on_script_changed")):
146 script_editor.disconnect("editor_script_changed", Callable(self,
147 "_on_script_changed"))
149 func _on_script_changed(file) -> void:
150 """Handle changing scripts"""
152 last_file_path = ProjectSettings.globalize_path(file.resource_path)
153 handle_activity(last_file_path)
155 #func _unhandled_key_input(event: InputEvent) -> void:
156 # """Handle key inputs"""
157 # var file = get_current_file()
158 # handle_activity(file)
160 func _save_external_data() -> void:
161 """Handle saving files"""
162 var file = get_current_file()
164 handle_activity(ProjectSettings.globalize_path(file.resource_path), true)
166 func _get_current_scene():
167 """Get currently used scene"""
168 if EditorInterface.get_edited_scene_root():
169 var file = EditorInterface.get_edited_scene_root()
171 return ProjectSettings.globalize_path(file.scene_file_path)
173 var file = get_current_file()
175 return ProjectSettings.globalize_path(file.resource_path)
179 func _on_scene_modified():
180 """Send heartbeat when scene is modified"""
181 var current_scene = get_tree().current_scene
183 handle_activity_scene(_get_current_scene())
185 func get_current_file():
186 """Get currently used script file"""
187 var file = get_editor_interface().get_script_editor().get_current_script()
189 last_file_path = ProjectSettings.globalize_path(file.resource_path)
191 return get_editor_interface().get_script_editor().get_current_script()
193 func handle_activity(file, is_write: bool = false) -> void:
194 """Handle user's activity"""
195 # If file that has activity in or wakatime cli doesn't exist, return
196 if not file or not Utils.wakatime_cli_exists(get_waka_cli()):
199 # If user is saving file or has changed path, or enough time has passed for a heartbeat - send it
200 #var filepath = ProjectSettings.globalize_path(file.resource_path)
201 if is_write or file != last_heartbeat.file_path or enough_time_passed():
202 send_heartbeat(file, is_write)
204 func handle_activity_scene(file, is_write: bool = false, changed_file: bool = false) -> void:
205 """Handle activity in scenes"""
206 if not file or not Utils.wakatime_cli_exists(get_waka_cli()):
209 if is_write or changed_file or enough_time_passed():
211 send_heartbeat(file, is_write)
213 func send_heartbeat(filepath: String, is_write: bool) -> void:
214 """Send Wakatimde heartbeat for the specified file"""
215 # Check Wakatime API key
216 var api_key = get_api_key()
218 Utils.plugin_print("Failed to get Wakatime API key. Are you sure it's correct?")
219 if (key_get_tries < 3):
223 Utils.plugin_print("If this keep occuring, please create a file: ~/.wakatime.cfg\n
224 initialize it with:\n[settings]\napi_key={your_key}")
227 # Make sure not to trigger additional heartbeats cause of events from scenes
230 file = last_file_path
231 #print("\n-------SCENE MODE--------\n")
234 var heartbeat = HeartBeat.new(file, Time.get_unix_time_from_system(), is_write)
236 # Current text editor
237 var text_editor = _find_active_script_editor()
238 var cursor_pos = _get_cursor_pos(text_editor)
240 # Append all informations as Wakatime CLI arguments
241 var cmd: Array[Variant] = ["--entity", filepath, "--key", api_key]
243 cmd.append("--write")
244 cmd.append_array(["--alternate-project", ProjectSettings.get("application/config/name")])
245 cmd.append_array(["--time", str(heartbeat.time)])
246 cmd.append_array(["--lineno", str(cursor_pos.line)])
247 cmd.append_array(["--cursorpos", str(cursor_pos.column)])
248 cmd.append_array(["--plugin", get_user_agent()])
250 cmd.append_array(["--alternate-language", "Scene"])
252 cmd.append_array(["--category", "building"])
254 cmd.append(["--category", "coding"])
256 # Send heartbeat using Wakatime CLI
257 var cmd_callable = Callable(self, "_handle_heartbeat").bind(cmd)
261 WorkerThreadPool.add_task(cmd_callable)
262 last_heartbeat = heartbeat
264 func _find_active_script_editor():
265 """Return currently used script editor"""
267 var script_editor = get_editor_interface().get_script_editor()
268 var current_editor = script_editor.get_current_editor()
270 # Try to find code editor from it
272 return _find_code_edit_recursive(script_editor.get_current_editor())
275 func _find_code_edit_recursive(node: Node) -> CodeEdit:
276 """Find recursively code editor in a node"""
277 # If node is already a code editor, return it
281 # Try to find it in every child of a given node
282 for child in node.get_children():
283 var editor = _find_code_edit_recursive(child)
288 func _get_cursor_pos(text_editor) -> Dictionary:
289 """Get cursor editor from the given text editor"""
293 "line": text_editor.get_caret_line() + 1,
294 "column": text_editor.get_caret_column() + 1
302 func _handle_heartbeat(cmd_arguments) -> void:
303 """Handle sending the heartbeat"""
304 # Get Wakatime CLI and try to send a heartbeat
305 if wakatime_cli == null:
306 wakatime_cli = Utils.get_waka_cli()
308 var output: Array[Variant] = []
309 var exit_code: int = OS.execute(wakatime_cli, cmd_arguments, output, true)
311 update_today_time(wakatime_cli)
313 # Inform about success or errors if user is in debug
316 Utils.plugin_print("Failed to send heartbeat: %s" % output)
318 Utils.plugin_print("Heartbeat sent: %s" % output)
320 func enough_time_passed():
321 """Check if enough time has passed for another heartbeat"""
322 return Time.get_unix_time_from_system() - last_heartbeat.time >= HeartBeat.FILE_MODIFIED_DELAY
324 func update_today_time(wakatime_cli) -> void:
325 """Update today's time in menu"""
326 var output: Array[Variant] = []
327 # Get today's time from Wakatime CLI
328 var exit_code: int = OS.execute(wakatime_cli, ["--today"], output, true)
330 # Convert it and combine different categories into
332 current_time = convert_time(output[0])
334 current_time = "Wakatime"
336 call_deferred("_update_panel_label", current_time, output[0])
338 func _update_panel_label(label: String, content: String):
339 """Update bottom panel name that shows time"""
340 # If counter exists and it has a label, update both the label and panel's name
341 if counter_instance and counter_instance.get_node("HBoxContainer/Label"):
342 counter_instance.get_node("HBoxContainer/Label").text = content
343 # Workaround to rename panel
344 remove_control_from_bottom_panel(counter_instance)
345 add_control_to_bottom_panel(counter_instance, label)
347 func convert_time(complex_time: String):
348 """Convert time from complex format into basic one, combine times"""
352 # Split times into categories
353 var time_categories = complex_time.split(', ')
354 for category in time_categories:
355 # Split time into parts, get first and third part (hours and minutes)
356 var time_parts = category.split(' ')
357 if time_parts.size() >= 3:
358 hours += int(time_parts[0])
359 minutes += int(time_parts[2])
361 # Wrap minutes into hours if needed
366 return str(hours) + " hrs, " + str(minutes) + " mins"
369 #------------------------------- FILE FUNCTIONS -------------------------------
370 func open_config() -> void:
371 """Open wakatime config file"""
372 OS.shell_open(Utils.config_filepath(system_platform, PLUGIN_PATH))
374 func get_waka_dir() -> String:
375 """Search for and return wakatime directory"""
376 if wakatime_dir == null:
377 wakatime_dir = "%s/.wakatime" % Utils.home_directory(system_platform, PLUGIN_PATH)
380 func get_waka_cli() -> String:
381 """Get wakatime_cli file"""
382 if wakatime_cli == null:
383 var build = Utils.get_waka_build(system_platform, system_architecture)
384 var ext: String = ".exe" if system_platform == "windows" else ''
385 wakatime_cli = "%s/%s%s" % [get_waka_dir(), build, ext]
388 func check_dependencies() -> void:
389 """Make sure all dependencies exist"""
390 if !Utils.wakatime_cli_exists(get_waka_cli()):
392 if !DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH):
393 download_decompressor()
395 func download_wakatime() -> void:
396 """Download wakatime cli"""
397 Utils.plugin_print("Downloading Wakatime CLI...")
398 var url: String = WAKATIME_URL_FMT.format({"wakatime_build":
399 Utils.get_waka_build(system_platform, system_architecture)})
401 # Try downloading wakatime
402 var http = HTTPRequest.new()
403 http.download_file = ZIP_PATH
404 http.connect("request_completed", Callable(self, "_wakatime_download_completed"))
408 var status = http.request(url)
410 Utils.plugin_print_err("Failed to start downloading Wakatime [Error: %s]" % status)
413 func download_decompressor() -> void:
414 """Download ouch decompressor"""
415 Utils.plugin_print("Downloading Ouch! decompression library...")
416 var url: String = DECOMPERSSOR_URL_FMT.format({"ouch_build":
417 Utils.get_ouch_build(system_platform)})
418 if system_platform == "windows":
421 # Try to download ouch
422 var http = HTTPRequest.new()
423 http.download_file = DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH)
424 http.connect("request_completed", Callable(self, "_decompressor_download_finished"))
428 var status = http.request(url)
431 Utils.plugin_print_err("Failed to start downloading Ouch! library [Error: %s]" % status)
433 func _wakatime_download_completed(result, status, headers, body) -> void:
434 """Finish downloading wakatime, handle errors"""
435 if result != HTTPRequest.RESULT_SUCCESS:
436 Utils.plugin_print_err("Error while downloading Wakatime CLI")
440 Utils.plugin_print("Wakatime CLI has been installed succesfully! Located at %s" % ZIP_PATH)
441 extract_files(ZIP_PATH, get_waka_dir())
443 func _decompressor_download_finished(result, status, headers, body) -> void:
444 """Handle errors and finishi decompressor download"""
445 # Error while downloading
446 if result != HTTPRequest.RESULT_SUCCESS:
447 Utils.plugin_print_err("Error while downloading Ouch! library")
452 if !DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH):
453 Utils.plugin_print_err("Failed to save Ouch! library")
457 # Save decompressor path, give write permissions to it
458 var decompressor: String = \
459 ProjectSettings.globalize_path(DecompressorUtils.decompressor_cli(decompressor_cli,
460 system_platform, PLUGIN_PATH))
462 if system_platform == "linux" or system_platform == "darwin":
463 OS.execute("chmod", ["+x", decompressor], [], true)
465 # Extract files, allowing usage of Ouch!
466 Utils.plugin_print("Ouch! has been installed succesfully! Located at %s" % \
467 DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH))
468 extract_files(ZIP_PATH, get_waka_dir())
470 func extract_files(source: String, destination: String) -> void:
471 """Extract downloaded Wakatime zip"""
472 # If decompression library and wakatime zip folder don't exist, return
473 if not DecompressorUtils.lib_exists(decompressor_cli, system_platform,
474 PLUGIN_PATH) and not Utils.wakatime_zip_exists(ZIP_PATH):
477 # Get paths as global
478 Utils.plugin_print("Extracting Wakatime...")
479 var decompressor: String
480 if system_platform == "windows":
481 decompressor = ProjectSettings.globalize_path(
482 DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH))
484 decompressor = ProjectSettings.globalize_path("res://" +
485 DecompressorUtils.decompressor_cli(decompressor_cli, system_platform, PLUGIN_PATH))
487 var src: String = ProjectSettings.globalize_path(source)
488 var dest: String = ProjectSettings.globalize_path(destination)
490 # Execute Ouch! decompression command, catch errors
491 var errors: Array[Variant] = []
492 var args: Array[String] = ["--yes", "decompress", src, "--dir", dest]
494 var error: int = OS.execute(decompressor, args, errors, true)
496 Utils.plugin_print(errors)
501 if Utils.wakatime_cli_exists(get_waka_cli()):
502 Utils.plugin_print("Wakatime CLI installed (path: %s)" % get_waka_cli())
504 Utils.plugin_print_err("Installation of Wakatime failed")
507 # Remove leftover files
511 """Delete files that aren't needed anymore"""
512 if Utils.wakatime_zip_exists(ZIP_PATH):
513 delete_file(ZIP_PATH)
514 if DecompressorUtils.lib_exists(decompressor_cli, system_platform, PLUGIN_PATH):
515 delete_file(ZIP_PATH)
517 func delete_file(path: String) -> void:
518 """Delete file at specified path"""
519 var dir: DirAccess = DirAccess.open("res://")
520 var status: int = dir.remove(path)
522 Utils.plugin_print_err("Failed to clean unnecesary file at %s" % path)
524 Utils.plugin_print("Clean unncecesary file at %s" % path)
526 #------------------------------- API KEY FUNCTIONS -------------------------------
529 """Get wakatime api key"""
531 # Handle errors while getting the key
532 var err = OS.execute(get_waka_cli(), ["--config-read", "api_key"], result)
536 # Trim API key from whitespaces and return it
537 var key = result[0].strip_edges()
542 func request_api_key() -> void:
543 """Request Wakatime API key from the user"""
545 var prompt = ApiKeyPrompt.instantiate()
546 _set_api_key(prompt, get_api_key())
547 _register_api_key_signals(prompt)
549 # Show prompt and hide it on request
551 prompt.popup_centered()
552 await prompt.popup_hide
555 func _set_api_key(prompt: PopupPanel, api_key) -> void:
556 """Set API key from prompt"""
557 # Safeguard against empty key
561 # Set correct text, to show API key
562 var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit")
563 edit_field.text = api_key
565 func _register_api_key_signals(prompt: PopupPanel) -> void:
566 """Connect all signals related to API key popup"""
568 var show_button: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/ShowButton")
569 var save_button: Node = prompt.get_node("VBoxContainer/HBoxContainerBottom/SubmitButton")
571 # Connect them to press events
572 show_button.connect("pressed", Callable(self, "_on_toggle_key_text").bind(prompt))
573 save_button.connect("pressed", Callable(self, "_on_save_key").bind(prompt))
574 prompt.connect("popup_hide", Callable(self, "_on_popup_hide").bind(prompt))
576 func _on_popup_hide(prompt: PopupPanel):
577 """Close the popup window when user wants to hide it"""
580 func _on_toggle_key_text(prompt: PopupPanel) -> void:
581 """Handle hiding and showing API key"""
583 var show_button: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/ShowButton")
584 var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit")
586 # Set the correct text and hide field if needed
587 edit_field.secret = not edit_field.secret
588 show_button.text = "Show" if edit_field.secret else "Hide"
590 func _on_save_key(prompt: PopupPanel) -> void:
591 """Handle entering API key"""
592 # Get text field node and api key that's entered
593 var edit_field: Node = prompt.get_node("VBoxContainer/HBoxContainerTop/LineEdit")
594 var api_key = edit_field.text.strip_edges()
596 # Try to set api key for wakatime and handle errors
597 var err: int = OS.execute(get_waka_cli(), ["--config-write", "api-key=%s" % api_key])
599 Utils.plugin_print("Failed to save API key")
600 prompt.visible = false
602 #------------------------------- PLUGIN INFORMATIONS -------------------------------
604 func get_user_agent() -> String:
605 """Get user agent identifier"""
606 var os_name = OS.get_name().to_lower()
607 return "godot/%s godot-wakatime/%s" % [
608 get_engine_version(),
609 _get_plugin_version()
612 func _get_plugin_name() -> String:
613 """Get name of the plugin"""
614 return "Godot_Super-Wakatime"
616 func _get_plugin_version() -> String:
617 """Get plugin version"""
620 func _get_editor_name() -> String:
621 """Get name of the editor"""
622 return "Godot%s" % get_engine_version()
624 func get_engine_version() -> String:
625 """Get verison of currently used engine"""
626 return "%s.%s.%s" % [Engine.get_version_info()["major"], Engine.get_version_info()["minor"],
627 Engine.get_version_info()["patch"]]