]> Repositories - rushbound.git/blobdiff - addons/godot_super-wakatime/godot_super-wakatime.gd
Install Godot Super Wakatime
[rushbound.git] / addons / godot_super-wakatime / godot_super-wakatime.gd
diff --git a/addons/godot_super-wakatime/godot_super-wakatime.gd b/addons/godot_super-wakatime/godot_super-wakatime.gd
new file mode 100644 (file)
index 0000000..42c1799
--- /dev/null
@@ -0,0 +1,627 @@
+@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()
+
+# Hearbeat class
+const HeartBeat = preload("res://addons/godot_super-wakatime/heartbeat.gd")
+var last_heartbeat = HeartBeat.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
+var last_scene_path: String = ''
+var last_file_path: String = ''
+var last_time: int = 0
+var previous_state: String = ''
+const LOG_INTERVAL: int = 60000
+var scene_mode: bool = false
+
+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)
+       
+       
+func _physics_process(delta: float) -> void:
+       """Process plugin changes over time"""
+       # Every 1000 frames check for updates
+       if Engine.get_physics_frames() % 1000 == 0:
+               # Check for scene change
+               var scene_root = get_editor_interface().get_edited_scene_root()
+               if scene_root:
+                       var current_scene_path = _get_current_scene()
+                       
+                       # If currently used scene is different thatn 1000 frames ago, log activity
+                       if current_scene_path != last_scene_path:
+                               last_scene_path = current_scene_path
+                               handle_activity_scene(current_scene_path, false, true)
+                               
+                       else:
+                               # Check for scene updates
+                               var current_scene = EditorInterface.get_edited_scene_root()
+                               if current_scene:
+                                       var state = generate_scene_state(current_scene)
+                                       # If current state is different than the previous one, handle activity
+                                       if state != previous_state:
+                                               previous_state = state
+                                               handle_activity_scene(current_scene_path, true)
+                                       # Otherwise just keep scene the same
+                                       else:
+                                               previous_state = state
+               else:
+                       last_scene_path = '' 
+                                       
+func generate_scene_state(node: Node) -> String:
+       """Generate a scene state identifier"""
+       var state = str(node.get_instance_id())
+       for child in node.get_children():
+               state += str(child.get_instance_id())
+       return str(state)
+       
+func _input(event: InputEvent) -> void:
+       """Handle all input events"""
+       # Key events
+       if event is InputEventKey:
+               var file = get_current_file()
+               if file:
+                       handle_activity(ProjectSettings.globalize_path(file.resource_path))
+       # Mouse button events
+       elif event is InputEventMouse and event.is_pressed():
+               var file = _get_current_scene()
+               if file != '' and file:
+                       handle_activity_scene(file)
+
+func setup_plugin() -> void:
+       """Setup Wakatime plugin, download dependencies if needed, initialize menu"""
+       Utils.plugin_print("Setting up %s" % get_user_agent())
+       check_dependencies()
+       
+       # 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)
+       
+       # Connect code editor signals
+       var script_editor: ScriptEditor = get_editor_interface().get_script_editor()
+       script_editor.call_deferred("connect", "editor_script_changed", Callable(self, 
+               "_on_script_changed"))
+
+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)
+       
+       # Disconnect script editor tracking
+       var script_editor: ScriptEditor = get_editor_interface().get_script_editor()
+       if script_editor.is_connected("editor_script_changed", Callable(self, 
+               "_on_script_changed")):
+               script_editor.disconnect("editor_script_changed", Callable(self, 
+                       "_on_script_changed"))
+               
+func _on_script_changed(file) -> void:
+       """Handle changing scripts"""
+       if file:
+               last_file_path = ProjectSettings.globalize_path(file.resource_path)
+       handle_activity(last_file_path)
+       
+#func _unhandled_key_input(event: InputEvent) -> void:
+#      """Handle key inputs"""
+#      var file = get_current_file()
+#      handle_activity(file)
+       
+func _save_external_data() -> void:
+       """Handle saving files"""
+       var file = get_current_file()
+       if file:
+               handle_activity(ProjectSettings.globalize_path(file.resource_path), true)
+       
+func _get_current_scene():
+       """Get currently used scene"""
+       if EditorInterface.get_edited_scene_root():
+               var file = EditorInterface.get_edited_scene_root()
+               if file:
+                       return ProjectSettings.globalize_path(file.scene_file_path)
+       else:
+               var file = get_current_file()
+               if file:
+                       return ProjectSettings.globalize_path(file.resource_path)
+                       
+       return null
+       
+func _on_scene_modified():
+       """Send heartbeat when scene is modified"""
+       var current_scene = get_tree().current_scene
+       if current_scene:
+               handle_activity_scene(_get_current_scene())
+       
+func get_current_file():
+       """Get currently used script file"""
+       var file = get_editor_interface().get_script_editor().get_current_script()
+       if file:
+               last_file_path = ProjectSettings.globalize_path(file.resource_path)
+       
+       return get_editor_interface().get_script_editor().get_current_script()
+               
+func handle_activity(file, is_write: bool = false) -> void:
+       """Handle user's activity"""
+       # If file that has activity in or wakatime cli doesn't exist, return
+       if not file or not Utils.wakatime_cli_exists(get_waka_cli()):
+               return
+       
+       # If user is saving file or has changed path, or enough time has passed for a heartbeat - send it
+       #var filepath = ProjectSettings.globalize_path(file.resource_path)
+       if is_write or file != last_heartbeat.file_path or enough_time_passed():
+               send_heartbeat(file, is_write)
+               
+func handle_activity_scene(file, is_write: bool = false, changed_file: bool = false) -> void:
+       """Handle activity in scenes"""
+       if not file or not Utils.wakatime_cli_exists(get_waka_cli()):
+               return
+               
+       if is_write or changed_file or enough_time_passed():
+               scene_mode = true
+               send_heartbeat(file, is_write)
+               
+func send_heartbeat(filepath: String, is_write: bool) -> void:
+       """Send Wakatimde heartbeat for the specified file"""
+       # 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
+               
+       # Make sure not to trigger additional heartbeats cause of events from scenes
+       var file = filepath
+       if scene_mode:
+               file = last_file_path
+               #print("\n-------SCENE MODE--------\n")
+               
+       # Create heartbeat
+       var heartbeat = HeartBeat.new(file, Time.get_unix_time_from_system(), is_write)
+       
+       # Current text editor
+       var text_editor = _find_active_script_editor()
+       var cursor_pos = _get_cursor_pos(text_editor)
+       
+       # 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(heartbeat.time)])
+       cmd.append_array(["--lineno", str(cursor_pos.line)])
+       cmd.append_array(["--cursorpos", str(cursor_pos.column)])
+       cmd.append_array(["--plugin", get_user_agent()])
+       
+       cmd.append_array(["--alternate-language", "Scene"])
+       if scene_mode:
+               cmd.append_array(["--category", "building"])
+       else:
+               cmd.append(["--category", "coding"])    
+       
+       # Send heartbeat using Wakatime CLI
+       var cmd_callable = Callable(self, "_handle_heartbeat").bind(cmd)
+       
+       scene_mode = false
+       
+       WorkerThreadPool.add_task(cmd_callable)
+       last_heartbeat = heartbeat
+       
+func _find_active_script_editor():
+       """Return currently used script editor"""
+       # Get script editor
+       var script_editor = get_editor_interface().get_script_editor()
+       var current_editor = script_editor.get_current_editor()
+       
+       # Try to find code editor from it
+       if current_editor:
+               return _find_code_edit_recursive(script_editor.get_current_editor())
+       return null
+
+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 _get_cursor_pos(text_editor) -> Dictionary:
+       """Get cursor editor from the given text editor"""
+       if text_editor:
+               
+               return {
+                       "line": text_editor.get_caret_line() + 1,
+                       "column": text_editor.get_caret_column() + 1
+               }
+               
+       return {
+               "line": 0,
+               "column": 0
+       }
+       
+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 enough_time_passed():
+       """Check if enough time has passed for another heartbeat"""
+       return Time.get_unix_time_from_system() - last_heartbeat.time >= HeartBeat.FILE_MODIFIED_DELAY
+       
+func update_today_time(wakatime_cli) -> void:
+       """Update today's time in menu"""
+       var output: Array[Variant] = []
+       # 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 = convert_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)
+               
+func convert_time(complex_time: String):
+       """Convert time from complex format into basic one, combine times"""
+       return complex_time
+       
+       """
+       # Split times into categories
+       var time_categories = complex_time.split(', ')
+       for category in time_categories:
+               # Split time into parts, get first and third part (hours and minutes)
+               var time_parts = category.split(' ')
+               if time_parts.size() >= 3:
+                       hours += int(time_parts[0])
+                       minutes += int(time_parts[2])
+       
+       # Wrap minutes into hours if needed
+       while minutes >= 60:
+               minutes -= 60
+               hours += 1
+
+       return str(hours) + " hrs, " + str(minutes) + " mins"
+       """
+
+#------------------------------- 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 godot-wakatime/%s" % [
+               get_engine_version(), 
+               _get_plugin_version()
+       ]
+       
+func _get_plugin_name() -> String:
+       """Get name of the plugin"""
+       return "Godot_Super-Wakatime"
+       
+func _get_plugin_version() -> String:
+       """Get plugin version"""
+       return "1.0.0"
+       
+func _get_editor_name() -> String:
+       """Get name of the editor"""
+       return "Godot%s" % get_engine_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"]]