commit 7770ce1ae37a01e40efc8b59402b3c6c90445229 Author: gdz Date: Sun Sep 7 14:57:10 2025 +0200 init diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0af181c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/.idea/.idea.openF1Manager/.idea/.gitignore b/.idea/.idea.openF1Manager/.idea/.gitignore new file mode 100644 index 0000000..8538fc6 --- /dev/null +++ b/.idea/.idea.openF1Manager/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/.idea.openF1Manager.iml +/contentModel.xml +/modules.xml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.openF1Manager/.idea/.name b/.idea/.idea.openF1Manager/.idea/.name new file mode 100644 index 0000000..7a7bfb3 --- /dev/null +++ b/.idea/.idea.openF1Manager/.idea/.name @@ -0,0 +1 @@ +openF1Manager \ No newline at end of file diff --git a/.idea/.idea.openF1Manager/.idea/encodings.xml b/.idea/.idea.openF1Manager/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.openF1Manager/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.openF1Manager/.idea/indexLayout.xml b/.idea/.idea.openF1Manager/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.openF1Manager/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.openF1Manager/.idea/libraries/GdSdk.xml b/.idea/.idea.openF1Manager/.idea/libraries/GdSdk.xml new file mode 100644 index 0000000..72a9c1b --- /dev/null +++ b/.idea/.idea.openF1Manager/.idea/libraries/GdSdk.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.openF1Manager/.idea/vcs.xml b/.idea/.idea.openF1Manager/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.openF1Manager/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..9d8b7fa --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..346cbc8 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://coxw1m7qyncsl" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/openF1Manager.csproj b/openF1Manager.csproj new file mode 100644 index 0000000..8eebc1d --- /dev/null +++ b/openF1Manager.csproj @@ -0,0 +1,6 @@ + + + net8.0 + true + + \ No newline at end of file diff --git a/openF1Manager.sln b/openF1Manager.sln new file mode 100644 index 0000000..d5f98e1 --- /dev/null +++ b/openF1Manager.sln @@ -0,0 +1,19 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2012 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "openF1Manager", "openF1Manager.csproj", "{096449E2-30A1-4ABB-991E-9CACF26B6297}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + ExportDebug|Any CPU = ExportDebug|Any CPU + ExportRelease|Any CPU = ExportRelease|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {096449E2-30A1-4ABB-991E-9CACF26B6297}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {096449E2-30A1-4ABB-991E-9CACF26B6297}.Debug|Any CPU.Build.0 = Debug|Any CPU + {096449E2-30A1-4ABB-991E-9CACF26B6297}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU + {096449E2-30A1-4ABB-991E-9CACF26B6297}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU + {096449E2-30A1-4ABB-991E-9CACF26B6297}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU + {096449E2-30A1-4ABB-991E-9CACF26B6297}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU + EndGlobalSection +EndGlobal diff --git a/openF1Manager.sln.DotSettings.user b/openF1Manager.sln.DotSettings.user new file mode 100644 index 0000000..a91bf20 --- /dev/null +++ b/openF1Manager.sln.DotSettings.user @@ -0,0 +1,3 @@ + + ForceIncluded + ForceIncluded \ No newline at end of file diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..fe19fb3 --- /dev/null +++ b/project.godot @@ -0,0 +1,25 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="openF1Manager" +run/main_scene="uid://cx2vkr0hbfe4c" +config/features=PackedStringArray("4.4", "C#", "GL Compatibility") +config/icon="res://icon.svg" + +[dotnet] + +project/assembly_name="openF1Manager" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/scenes/Main/Main.cs b/scenes/Main/Main.cs new file mode 100644 index 0000000..29d633d --- /dev/null +++ b/scenes/Main/Main.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.Text; +using Godot; + +namespace openF1Manager.scenes.Main; + +public partial class Main : Node +{ + public enum APIEndpoints + { + CarData, + Drivers, + Intervals, + Laps, + Location, + Meetings, + Overtakes, + Pit, + Position, + RaceControl, + Sessions, + SessionResult, + StartingGrid, + Stints, + TeamRadio, + Weather + } + + private static readonly Dictionary enpointUrl = new() + { + { APIEndpoints.CarData, "car_data" }, + { APIEndpoints.Drivers, "drivers" }, + { APIEndpoints.Intervals, "intervals" }, + { APIEndpoints.Laps, "laps" }, + { APIEndpoints.Location, "location" }, + { APIEndpoints.Meetings, "meetings" }, + { APIEndpoints.Overtakes, "overtakes" }, + { APIEndpoints.Pit, "pit" }, + { APIEndpoints.Position, "position" }, + { APIEndpoints.RaceControl, "race_control" }, + { APIEndpoints.Sessions, "sessions" }, + { APIEndpoints.SessionResult, "session_result" }, + { APIEndpoints.StartingGrid, "starting_grid" }, + { APIEndpoints.Stints, "stints" }, + { APIEndpoints.TeamRadio, "team_radio" }, + { APIEndpoints.Weather, "weather" }, + }; + + [Export] public APIEndpoints Enpoint = APIEndpoints.Sessions; + private string openf1Url = "https://api.openf1.org/v1/"; + + [Export] public string options = "date_start>=2023-09-01&date_end<=2023-09-30"; + + public override async void _Ready() + { + HttpRequest httpRequest = GetNode("HTTPRequest"); + // httpRequest.RequestCompleted += OnRequestCompleted; + + // Created address + GD.Print("Trying created address"); + string requestUrl = $"{openf1Url}{enpointUrl[Enpoint]}?{options}"; + GD.Print(requestUrl); + //var error = httpRequest.Request(requestUrl); + + +// await ToSignal(httpRequest, "request_completed"); + + // // Correct address + // GD.Print("Trying hardcoded address"); + // requestUrl = "https://api.openf1.org/v1/sessions?date_start%3E%3D2023-09-01&date_end%3C%3D2023-09-30"; + // GD.Print("https://api.openf1.org/v1/sessions?date_start%3E%3D2023-09-01&date_end%3C%3D2023-09-30"); + // error = httpRequest.Request(requestUrl); + // if (error != Error.Ok) + // { + // GD.Print(error); + // return; + // } + + var children = GetChildren(); + + foreach (var child in children) + { + if (child is Control control) + { + GD.Print(control.Name); + } + } + } + + private void OnRequestCompleted(long result, long responseCode, string[] headers, byte[] body) + { + var text = Encoding.UTF8.GetString(body); + var parsed = Json.ParseString(text); + + if (parsed.VariantType == Variant.Type.Array) + { + var arr = parsed.AsGodotArray(); + GD.Print($"Array items: {arr.Count}"); + foreach (var item in arr) + { + // Each item is typically an object/dictionary; print a few fields or all key-values + if (item.VariantType == Variant.Type.Dictionary) + { + var dict = item.AsGodotDictionary(); + foreach (var (k, v) in dict) + { + GD.Print($"{k}: {v}"); + } + GD.Print("----"); + } + else + { + GD.Print(item); + } + } + } + else if (parsed.VariantType == Variant.Type.Dictionary) + { + var json = parsed.AsGodotDictionary(); + GD.Print($"Object keys: {json.Count}"); + foreach (var (key, value) in json) + { + GD.Print($"{key}: {value}"); + } + } + else + { + GD.PrintErr($"Unexpected JSON root type: {parsed.VariantType}"); + GD.Print(text); // Optional: inspect raw payload + } + } +} \ No newline at end of file diff --git a/scenes/Main/Main.cs.uid b/scenes/Main/Main.cs.uid new file mode 100644 index 0000000..2e96aec --- /dev/null +++ b/scenes/Main/Main.cs.uid @@ -0,0 +1 @@ +uid://cj1o6v4tq5tiq diff --git a/scenes/Main/main.tscn b/scenes/Main/main.tscn new file mode 100644 index 0000000..1111a5d --- /dev/null +++ b/scenes/Main/main.tscn @@ -0,0 +1,55 @@ +[gd_scene load_steps=3 format=3 uid="uid://cx2vkr0hbfe4c"] + +[ext_resource type="Script" uid="uid://cj1o6v4tq5tiq" path="res://scenes/Main/Main.cs" id="1_gol0p"] +[ext_resource type="PackedScene" uid="uid://icuiqfoy5pkr" path="res://scenes/Tools/session_tool.tscn" id="2_br8qb"] + +[node name="Main" type="Node"] +script = ExtResource("1_gol0p") + +[node name="HTTPRequest" type="HTTPRequest" parent="."] + +[node name="HUD" type="MarginContainer" parent="."] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +size_flags_horizontal = 4 +size_flags_vertical = 4 + +[node name="SessionTool" parent="HUD" instance=ExtResource("2_br8qb")] +layout_mode = 2 + +[node name="GlobalTool" type="MarginContainer" parent="HUD"] +clip_contents = true +layout_mode = 2 +size_flags_horizontal = 4 +size_flags_vertical = 4 + +[node name="BoxContainer" type="BoxContainer" parent="HUD/GlobalTool"] +layout_mode = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="HUD/GlobalTool/BoxContainer"] +layout_mode = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="HUD/GlobalTool/BoxContainer/VBoxContainer"] +layout_mode = 2 + +[node name="Label" type="Label" parent="HUD/GlobalTool/BoxContainer/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "openF1 Manager" + +[node name="Button" type="Button" parent="HUD/GlobalTool/BoxContainer/VBoxContainer/HBoxContainer"] +layout_mode = 2 +text = "Send Request" + +[node name="ItemList" type="ItemList" parent="HUD/GlobalTool/BoxContainer/VBoxContainer"] +clip_contents = false +custom_minimum_size = Vector2(150, 20) +layout_mode = 2 +size_flags_horizontal = 3 +max_text_lines = 3 +auto_width = true +text_overrun_behavior = 2 +item_count = 1 +item_0/text = "None" diff --git a/scenes/Tools/SessionTool.cs b/scenes/Tools/SessionTool.cs new file mode 100644 index 0000000..c871f2f --- /dev/null +++ b/scenes/Tools/SessionTool.cs @@ -0,0 +1,154 @@ +using System.Text; +using Godot; +using openF1Manager.scripts; + +namespace openF1Manager.scenes.Main; + +public partial class SessionTool : MarginContainer +{ + private LineEdit _meetingKey; + private LineEdit _sessionKey; + private LineEdit _location; + private LineEdit _dateStart; + private LineEdit _dateEnd; + + private string _baseAddress = "https://api.openf1.org/v1/sessions?"; + + // MVVM-lite + private SessionFilter _vm; + private FormBinder _binder; + + public override void _Ready() + { + _meetingKey = + GetNode( + "ElementsVBoxContainer/PropertiesVBoxContainer/MeetingKeyHBoxContainer/MeetingKeyLineEdit"); + _sessionKey = + GetNode( + "ElementsVBoxContainer/PropertiesVBoxContainer/SessionKeyHBoxContainer/SessionKeyLineEdit"); + _location = GetNode( + "ElementsVBoxContainer/PropertiesVBoxContainer/LocationHBoxContainer/LocationLineEdit"); + _dateStart = + GetNode("ElementsVBoxContainer/PropertiesVBoxContainer/DateStartHBoxContainer/DateStartLineEdit"); + _dateEnd = GetNode( + "ElementsVBoxContainer/PropertiesVBoxContainer/DateEndHBoxContainer/DateEndLineEdit"); + + GD.Print(_meetingKey.PlaceholderText); + + // Create VM and bind all LineEdits under the form root + _vm = new SessionFilter(); + var formRoot = GetNode("ElementsVBoxContainer/PropertiesVBoxContainer"); + _binder = new FormBinder(_vm, this).Bind(); + } + + + public string BuildQueryOld() + { + GD.Print("Building query"); + var queryString = System.Web.HttpUtility.ParseQueryString(string.Empty); + + foreach (var pair in new (string Key, string Value)[] + { + ("meeting_key", _meetingKey.Text), + ("session_key", _sessionKey.Text), + ("location", _location.Text), + ("date_start", _dateStart.Text), + ("date_end", _dateEnd.Text), + }) + { + if (!string.IsNullOrWhiteSpace(pair.Value)) + queryString[pair.Key] = pair.Value; + } + + return _baseAddress + queryString.ToString(); + } + + public string BuildQuery() + { + GD.Print("Building query"); + return ApiModelSerializer.ToQueryString(_vm, _baseAddress); + } + + public async void SendRequest() + { + string requestAddress = BuildQuery(); + GD.Print(requestAddress); + var httpRequest = GetParent().GetParent().GetNode("HTTPRequest"); + httpRequest.RequestCompleted += OnSessionRequestCompleted; + + GD.Print("Sending request"); + var error = httpRequest.Request(requestAddress); + + if (error != Error.Ok) + { + GD.Print(error); + return; + } + } + + private void OnSessionRequestCompleted(long result, long responseCode, string[] headers, byte[] body) + { + GD.Print("Request completed"); + var text = Encoding.UTF8.GetString(body); + var parsed = Json.ParseString(text); + + if (parsed.VariantType != Variant.Type.Array) + { + GD.PrintErr("Unexpected JSON format. Expected array."); + return; + } + + var arr = parsed.AsGodotArray(); + if (arr.Count == 0) + { + GD.Print("No sessions returned."); + return; + } + + // Populate List of sessions + ItemList itemList = GetNode("ElementsVBoxContainer/ControlsVBoxContainer/ItemList"); + itemList.Clear(); + + int count = 0; + foreach (var item in arr) + { + count++; + itemList.AddItem(count.ToString()); + } + + // Populate UI fields from first item in array + var first = arr[0]; + if (first.VariantType != Variant.Type.Dictionary) + { + GD.PrintErr("Unexpected item format. Expected dictionary."); + return; + } + + var dict = first.AsGodotDictionary(); + + // Populate VM; binder reflects it to UI fields + ApiModelSerializer.PopulateFromDictionary(_vm, dict); + } + + private void PopulateFieldsFromDictionary(Godot.Collections.Dictionary dict) + { + // Helper to read a value with multiple possible key casings + string GetString(params string[] keys) + { + foreach (var k in keys) + { + if (dict.ContainsKey(k) && dict[k].VariantType != Variant.Type.Nil) + return dict[k].ToString(); + } + return string.Empty; + } + + // Map likely API keys; try snake_case first, then camelCase fallbacks + _meetingKey.Text = GetString("meeting_key", "meetingKey"); + _sessionKey.Text = GetString("session_key", "sessionKey"); + _location.Text = GetString("location", "Location"); + _dateStart.Text = GetString("date_start", "dateStart", "DateStart"); + _dateEnd.Text = GetString("date_end", "dateEnd", "DateEnd"); + } + +} \ No newline at end of file diff --git a/scenes/Tools/SessionTool.cs.uid b/scenes/Tools/SessionTool.cs.uid new file mode 100644 index 0000000..c374cfb --- /dev/null +++ b/scenes/Tools/SessionTool.cs.uid @@ -0,0 +1 @@ +uid://ccc1qonkjw7k8 diff --git a/scenes/Tools/session_tool.tscn b/scenes/Tools/session_tool.tscn new file mode 100644 index 0000000..6efa39d --- /dev/null +++ b/scenes/Tools/session_tool.tscn @@ -0,0 +1,222 @@ +[gd_scene load_steps=4 format=3 uid="uid://icuiqfoy5pkr"] + +[ext_resource type="Script" uid="uid://ccc1qonkjw7k8" path="res://scenes/Tools/SessionTool.cs" id="1_icata"] + +[sub_resource type="SystemFont" id="SystemFont_br8qb"] + +[sub_resource type="Theme" id="Theme_px18m"] +default_font = SubResource("SystemFont_br8qb") + +[node name="SessionTool" type="MarginContainer"] +clip_contents = true +size_flags_horizontal = 0 +size_flags_vertical = 4 +theme = SubResource("Theme_px18m") +script = ExtResource("1_icata") + +[node name="ElementsBoxContainer" type="BoxContainer" parent="."] +layout_mode = 2 +vertical = true + +[node name="PropertiesVBoxContainer" type="VBoxContainer" parent="ElementsBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 1 + +[node name="MeetingKeyHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="MeetingKeyLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/MeetingKeyHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Meeting Key:" + +[node name="MeetingKeyLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/MeetingKeyHBoxContainer"] +layout_mode = 2 +placeholder_text = "latest" +expand_to_text_length = true +clear_button_enabled = true + +[node name="SessionKeyHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="SessionKeyLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/SessionKeyHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Session Key: " + +[node name="SessionKeyLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/SessionKeyHBoxContainer"] +layout_mode = 2 +placeholder_text = "latest" +expand_to_text_length = true +clear_button_enabled = true + +[node name="LocationHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="LocationLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/LocationHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Location:" + +[node name="LocationLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/LocationHBoxContainer"] +layout_mode = 2 +placeholder_text = "Monza" +expand_to_text_length = true +clear_button_enabled = true + +[node name="DateStartHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="DateStartLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/DateStartHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Date Start:" + +[node name="DateStartLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/DateStartHBoxContainer"] +layout_mode = 2 +placeholder_text = "2023-09-01T11:30:00+00:00" +expand_to_text_length = true +clear_button_enabled = true + +[node name="DateEndHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="DateEndLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/DateEndHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Date End:" + +[node name="DateEndLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/DateEndHBoxContainer"] +layout_mode = 2 +placeholder_text = "2023-09-01T11:30:00+00:00" +expand_to_text_length = true +clear_button_enabled = true + +[node name="SessionTypeHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="SessionTypeLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/SessionTypeHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Session Type:" + +[node name="SessionTypeLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/SessionTypeHBoxContainer"] +layout_mode = 2 +placeholder_text = "Practice" +expand_to_text_length = true +clear_button_enabled = true + +[node name="SessionNameHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="SessionNameLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/SessionNameHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Session Name:" + +[node name="SessionNameLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/SessionNameHBoxContainer"] +layout_mode = 2 +placeholder_text = "Practice 1" +expand_to_text_length = true +clear_button_enabled = true + +[node name="CountryKeyHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="CountryKeyLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/CountryKeyHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Country Key:" + +[node name="CountryKeyLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/CountryKeyHBoxContainer"] +layout_mode = 2 +placeholder_text = "13.0" +expand_to_text_length = true +clear_button_enabled = true + +[node name="CountryCodeHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="CountryCodeLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/CountryCodeHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Country Code:" + +[node name="CountryCodeLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/CountryCodeHBoxContainer"] +layout_mode = 2 +placeholder_text = "ITA" +expand_to_text_length = true +clear_button_enabled = true + +[node name="CountryNameHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="CountryNameLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/CountryNameHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Country Name:" + +[node name="CountryNameLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/CountryNameHBoxContainer"] +layout_mode = 2 +placeholder_text = "Italy" +expand_to_text_length = true +clear_button_enabled = true + +[node name="CircuitKeyHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="CircuitKeyLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/CircuitKeyHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Circuit Key:" + +[node name="CircuitKeyLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/CircuitKeyHBoxContainer"] +layout_mode = 2 +placeholder_text = "39.0" +expand_to_text_length = true +clear_button_enabled = true + +[node name="CircuitShortNameHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="CircuitShortNameLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/CircuitShortNameHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Circuit Short Name:" + +[node name="CircuitShortNameLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/CircuitShortNameHBoxContainer"] +layout_mode = 2 +placeholder_text = "Monza" +expand_to_text_length = true +clear_button_enabled = true + +[node name="GmtOffsetHBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="GmtOffsetLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/GmtOffsetHBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "GMT Offset:" + +[node name="GmtOffsetLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/GmtOffsetHBoxContainer"] +layout_mode = 2 +placeholder_text = "02:00:00" +expand_to_text_length = true +clear_button_enabled = true + +[node name="YearBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer/PropertiesVBoxContainer"] +layout_mode = 2 + +[node name="YearLabel" type="Label" parent="ElementsBoxContainer/PropertiesVBoxContainer/YearBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Year:" + +[node name="YearLineEdit" type="LineEdit" parent="ElementsBoxContainer/PropertiesVBoxContainer/YearBoxContainer"] +layout_mode = 2 +placeholder_text = "2023.0" +expand_to_text_length = true +clear_button_enabled = true + +[node name="ControlsVBoxContainer" type="HBoxContainer" parent="ElementsBoxContainer"] +layout_mode = 2 diff --git a/scripts/ApiModelSerializer.cs b/scripts/ApiModelSerializer.cs new file mode 100644 index 0000000..1b77a59 --- /dev/null +++ b/scripts/ApiModelSerializer.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using Godot; +using Godot.Collections; +using openF1Manager.scripts; + +public static class ApiModelSerializer +{ + public static string ToQueryString(object model, string baseAddress) + { + var query = System.Web.HttpUtility.ParseQueryString(string.Empty); + foreach (var (key, value) in EnumerateKeyValues(model)) + { + if (!string.IsNullOrWhiteSpace(value)) + query[key] = value; + } + + var queryString = query.ToString(); + return string.IsNullOrEmpty(queryString) ? baseAddress : $"{baseAddress}?{queryString}"; + } + + public static void PopulateFromDictionary(object model, Godot.Collections.Dictionary dict) + { + var type = model.GetType(); + foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!prop.CanWrite) continue; + + var attr = prop.GetCustomAttribute(); + var key = attr?.Key ?? ToSnakeCase(prop.Name); + + // Try snake_case first, then camelCase fallbacks + if (!TryGet(dict, key, out var str) && !TryGet(dict, SnakeToCamel(key), out str)) + continue; + + // You can add type conversions if needed; here we only set strings + if (prop.PropertyType == typeof(string) || prop.PropertyType == typeof(DateTime) || prop.PropertyType == typeof(int) || prop.PropertyType == typeof(float)) + prop.SetValue(model, str); + } + } + + private static bool TryGet(Dictionary dict, string key, out string value) + { + if (dict.ContainsKey(key) && dict[key].VariantType != Variant.Type.Nil) + { + value = dict[key].ToString(); + return true; + } + + value = string.Empty; + return false; + } + + private static IEnumerable<(string Key, string Value)> EnumerateKeyValues(object model) + { + var type = model.GetType(); + foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!prop.CanRead) continue; + + var value = prop.GetValue(model)?.ToString(); + var attr = prop.GetCustomAttribute(); + var key = attr?.Key ?? ToSnakeCase(prop.Name); + yield return (key, value); + } + } + + private static string ToSnakeCase(string name) + { + var sb = new StringBuilder(); + for (int i = 0; i < name.Length; i++) + { + var c = name[i]; + sb.Append(i > 0 && char.IsUpper(c) ? "_" + char.ToLower(c) : c.ToString()); + } + + return sb.ToString(); + } + + private static string SnakeToCamel(string name) + { + var sb = new StringBuilder(); + sb.Append(char.ToLower(name[0])); + for (int i = 1; i < name.Length; i++) + { + var c = name[i]; + sb.Append(char.IsUpper(c) ? char.ToLower(c) : c); + } + + return sb.ToString(); + } +} diff --git a/scripts/ApiModelSerializer.cs.uid b/scripts/ApiModelSerializer.cs.uid new file mode 100644 index 0000000..f1dbd38 --- /dev/null +++ b/scripts/ApiModelSerializer.cs.uid @@ -0,0 +1 @@ +uid://kos10xtghum5 diff --git a/scripts/FormBinder.cs b/scripts/FormBinder.cs new file mode 100644 index 0000000..72dc9f1 --- /dev/null +++ b/scripts/FormBinder.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reflection; +using Godot; + +public sealed class FormBinder where TViewModel : INotifyPropertyChanged +{ + private readonly TViewModel _vm; + private readonly Control _root; + private readonly Dictionary _lineToProp = new(); + private readonly Dictionary _propNameToLine = new(StringComparer.OrdinalIgnoreCase); + private bool _suppress; + + public FormBinder(TViewModel vm, Control root) + { + _vm = vm; + _root = root; + } + + public FormBinder Bind() + { + Discover(); + _vm.PropertyChanged += OnPropertyChanged; + foreach (var (line, prop) in _lineToProp) + { + line.TextChanged += (string text) => + { + if (_suppress) return; + if (prop.PropertyType == typeof(string) || prop.PropertyType == typeof(string)) + prop.SetValue(_vm, text); + }; + } + // Initial sync UI <- VM + PushAllFromVm(); + return this; + } + + private void Discover() + { + var props = typeof(TViewModel).GetProperties(BindingFlags.Instance | BindingFlags.Public); + var propMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var p in props) if (p.CanRead || p.CanWrite) propMap[p.Name] = p; + + void Walk(Node n) + { + if (n is LineEdit le) + { + // Prefer explicit metadata "vm_prop" to map to a property name; fallback to node name. + string propName = le.HasMeta("vm_prop") ? (string)le.GetMeta("vm_prop") : StripSuffix(le.Name, "LineEdit"); + if (propMap.TryGetValue(propName, out var pi)) + { + _lineToProp[le] = pi; + _propNameToLine[propName] = le; + } + } + foreach (var c in n.GetChildren()) + if (c is Node child) Walk(child); + } + + Walk(_root); + } + + private static string StripSuffix(string s, string suffix) => + s.EndsWith(suffix, StringComparison.Ordinal) ? s[..^suffix.Length] : s; + + private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == null) return; + if (_propNameToLine.TryGetValue(e.PropertyName, out var le)) + { + _suppress = true; + try { le.Text = _lineToProp[le].GetValue(_vm)?.ToString() ?? string.Empty; } + finally { _suppress = false; } + } + } + + public void PushAllFromVm() + { + _suppress = true; + try + { + foreach (var (line, prop) in _lineToProp) + line.Text = prop.GetValue(_vm)?.ToString() ?? string.Empty; + } + finally { _suppress = false; } + } +} diff --git a/scripts/FormBinder.cs.uid b/scripts/FormBinder.cs.uid new file mode 100644 index 0000000..49ffce1 --- /dev/null +++ b/scripts/FormBinder.cs.uid @@ -0,0 +1 @@ +uid://4puwe430cmav diff --git a/scripts/SessionFilter.cs b/scripts/SessionFilter.cs new file mode 100644 index 0000000..39337db --- /dev/null +++ b/scripts/SessionFilter.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace openF1Manager.scripts; + +/// +/// An attribute that is applied to a property to associate it with a specific API field key. +/// +/// +/// This attribute is used to map a class property to a corresponding field in the API. +/// It allows specifying a key that identifies the corresponding API field. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class ApiFieldAttribute : Attribute +{ + public string Key { get; } + public ApiFieldAttribute(string key) => Key = key; +} + + +public partial class SessionFilter : INotifyPropertyChanged +{ + private string? _meetingKey; + + + private string? _sessionKey; + private string? _location; + private DateTime? _dateStart; + private DateTime? _dateEnd; + private string? _sessionType; + private string? _sessionName; + private int? _countryKey; + private string? _countryCode; + private string? _countryName; + private int? _circuitKey; + private string? _circuitShortName; + private string? _gmtOffset; + private string? _year; + + [ApiField("meeting_key")] + public string MeetingKey + { + get => _meetingKey; + set => SetField(ref _meetingKey, value); + } + + [ApiField("session_key")] + public string SessionKey + { + get => _sessionKey; + set => SetField(ref _sessionKey, value); + } + + [ApiField("location")] + public string Location + { + get => _location; + set => SetField(ref _location, value); + } + + [ApiField("date_start")] + public DateTime? DateStart + { + get => _dateStart; + set => SetField(ref _dateStart, value); + } + + [ApiField("date_en")] + public DateTime? DateEnd + { + get => _dateEnd; + set => SetField(ref _dateEnd, value); + } + + [ApiField("session_type")] + public string SessionType + { + get => _sessionType; + set => SetField(ref _sessionType, value); + } + + [ApiField("session_name")] + public string SessionName + { + get => _sessionName; + set => SetField(ref _sessionName, value); + } + + [ApiField("country_key")] + public int? CountryKey + { + get => _countryKey; + set => SetField(ref _countryKey, value); + } + + [ApiField("country_code")] + public string CountryCode + { + get => _countryCode; + set => SetField(ref _countryCode, value); + } + + [ApiField("country_name")] + public string CountryName + { + get => _countryName; + set => SetField(ref _countryName, value); + } + + [ApiField("circuit_key")] + public int? CircuitKey + { + get => _circuitKey; + set => SetField(ref _circuitKey, value); + } + + [ApiField("circuit_short_name")] + public string CircuitShortName + { + get => _circuitShortName; + set => SetField(ref _circuitShortName, value); + } + + [ApiField("gmt_offset")] + public string GmtOffset + { + get => _gmtOffset; + set => SetField(ref _gmtOffset, value); + } + + [ApiField("year")] + public string Year + { + get => _year; + set => SetField(ref _year, value); + } + + public event PropertyChangedEventHandler PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + protected bool SetField(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + OnPropertyChanged(propertyName); + return true; + } +} \ No newline at end of file diff --git a/scripts/SessionFilter.cs.uid b/scripts/SessionFilter.cs.uid new file mode 100644 index 0000000..7c51279 --- /dev/null +++ b/scripts/SessionFilter.cs.uid @@ -0,0 +1 @@ +uid://db7e6qcg77eky