From fbf3b6826c34096e600e87b65136f9ca38ea9623 Mon Sep 17 00:00:00 2001 From: Tomi Eckert Date: Mon, 15 Dec 2025 09:26:27 +0100 Subject: [PATCH] complete refactor --- Blueberry.Redmine/Blueberry.Redmine.csproj | 14 + Blueberry.Redmine/Dto/CustomFieldList.cs | 95 +++++ Blueberry.Redmine/Dto/DetailedIssue.cs | 197 +++++++++ Blueberry.Redmine/Dto/IResponseList.cs | 9 + Blueberry.Redmine/Dto/IssueList.cs | 183 +++++++++ Blueberry.Redmine/Dto/NewIssue.cs | 131 ++++++ Blueberry.Redmine/Dto/PriorityList.cs | 32 ++ Blueberry.Redmine/Dto/ProjectList.cs | 83 ++++ Blueberry.Redmine/Dto/ProjectTrackers.cs | 89 ++++ Blueberry.Redmine/Dto/StatusList.cs | 29 ++ Blueberry.Redmine/Dto/UserInfo.cs | 74 ++++ Blueberry.Redmine/Dto/UserTime.cs | 107 +++++ Blueberry.Redmine/RedmineApiClient.cs | 282 +++++++++++++ .../RedmineApiException.cs | 2 +- Blueberry.Redmine/RedmineCache.cs | 208 ++++++++++ .../RedmineConfig.cs | 4 +- Blueberry.Redmine/RedmineManager.cs | 167 ++++++++ .../RedmineSettingsManager.cs | 29 +- Blueberry.sln | 14 +- Blueberry/App.xaml.cs | 10 +- Blueberry/Blueberry.csproj | 4 + Blueberry/Constants.cs | 21 +- Blueberry/IssueWindow.xaml | 144 +++++++ Blueberry/IssueWindow.xaml.cs | 214 ++++++++++ Blueberry/MainWindow.xaml | 123 ++++-- Blueberry/MainWindow.xaml.cs | 311 ++++++++++---- Blueberry/Redmine/AsyncLock.cs | 29 -- Blueberry/Redmine/IRedmineCache.cs | 10 - Blueberry/Redmine/IRedmineConnect.cs | 18 - Blueberry/Redmine/IRedmineManager.cs | 16 - Blueberry/Redmine/RedmineAuthHandler.cs | 51 --- Blueberry/Redmine/RedmineCache.cs | 64 --- Blueberry/Redmine/RedmineConnect.cs | 379 ------------------ Blueberry/Redmine/RedmineDto.cs | 185 --------- Blueberry/Redmine/RedmineManager.cs | 206 ---------- Blueberry/StringToColorConverter.cs | 80 ++++ Blueberry/TimeTrackerWindow.xaml | 65 +++ Blueberry/TimeTrackerWindow.xaml.cs | 108 +++++ Blueberry/UpdateManager.cs | 120 ++++++ BlueberryUpdater/BlueberryUpdater.csproj | 30 -- BlueberryUpdater/Program.cs | 246 ------------ BlueberryUpdater/app.manifest | 79 ---- README.md | 192 +++++++++ {BlueberryUpdater => images}/bb.ico | Bin installer.ps1 | 21 + 45 files changed, 3001 insertions(+), 1474 deletions(-) create mode 100644 Blueberry.Redmine/Blueberry.Redmine.csproj create mode 100644 Blueberry.Redmine/Dto/CustomFieldList.cs create mode 100644 Blueberry.Redmine/Dto/DetailedIssue.cs create mode 100644 Blueberry.Redmine/Dto/IResponseList.cs create mode 100644 Blueberry.Redmine/Dto/IssueList.cs create mode 100644 Blueberry.Redmine/Dto/NewIssue.cs create mode 100644 Blueberry.Redmine/Dto/PriorityList.cs create mode 100644 Blueberry.Redmine/Dto/ProjectList.cs create mode 100644 Blueberry.Redmine/Dto/ProjectTrackers.cs create mode 100644 Blueberry.Redmine/Dto/StatusList.cs create mode 100644 Blueberry.Redmine/Dto/UserInfo.cs create mode 100644 Blueberry.Redmine/Dto/UserTime.cs create mode 100644 Blueberry.Redmine/RedmineApiClient.cs rename {Blueberry/Redmine => Blueberry.Redmine}/RedmineApiException.cs (94%) create mode 100644 Blueberry.Redmine/RedmineCache.cs rename {Blueberry/Redmine => Blueberry.Redmine}/RedmineConfig.cs (64%) create mode 100644 Blueberry.Redmine/RedmineManager.cs rename Blueberry/SettingsManager.cs => Blueberry.Redmine/RedmineSettingsManager.cs (77%) create mode 100644 Blueberry/IssueWindow.xaml create mode 100644 Blueberry/IssueWindow.xaml.cs delete mode 100644 Blueberry/Redmine/AsyncLock.cs delete mode 100644 Blueberry/Redmine/IRedmineCache.cs delete mode 100644 Blueberry/Redmine/IRedmineConnect.cs delete mode 100644 Blueberry/Redmine/IRedmineManager.cs delete mode 100644 Blueberry/Redmine/RedmineAuthHandler.cs delete mode 100644 Blueberry/Redmine/RedmineCache.cs delete mode 100644 Blueberry/Redmine/RedmineConnect.cs delete mode 100644 Blueberry/Redmine/RedmineDto.cs delete mode 100644 Blueberry/Redmine/RedmineManager.cs create mode 100644 Blueberry/StringToColorConverter.cs create mode 100644 Blueberry/TimeTrackerWindow.xaml create mode 100644 Blueberry/TimeTrackerWindow.xaml.cs create mode 100644 Blueberry/UpdateManager.cs delete mode 100644 BlueberryUpdater/BlueberryUpdater.csproj delete mode 100644 BlueberryUpdater/Program.cs delete mode 100644 BlueberryUpdater/app.manifest create mode 100644 README.md rename {BlueberryUpdater => images}/bb.ico (100%) create mode 100644 installer.ps1 diff --git a/Blueberry.Redmine/Blueberry.Redmine.csproj b/Blueberry.Redmine/Blueberry.Redmine.csproj new file mode 100644 index 0000000..0d927c6 --- /dev/null +++ b/Blueberry.Redmine/Blueberry.Redmine.csproj @@ -0,0 +1,14 @@ + + + + net8.0-windows + enable + enable + + + + + + + + diff --git a/Blueberry.Redmine/Dto/CustomFieldList.cs b/Blueberry.Redmine/Dto/CustomFieldList.cs new file mode 100644 index 0000000..0fd6c63 --- /dev/null +++ b/Blueberry.Redmine/Dto/CustomFieldList.cs @@ -0,0 +1,95 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class CustomFieldList + { + public class CustomField + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("customized_type")] + public string CustomizedType { get; set; } + + [JsonPropertyName("field_format")] + public string FieldFormat { get; set; } + + [JsonPropertyName("regexp")] + public string Regexp { get; set; } + + [JsonPropertyName("min_length")] + public int? MinLength { get; set; } + + [JsonPropertyName("max_length")] + public int? MaxLength { get; set; } + + [JsonPropertyName("is_required")] + public bool IsRequired { get; set; } + + [JsonPropertyName("is_filter")] + public bool IsFilter { get; set; } + + [JsonPropertyName("searchable")] + public bool Searchable { get; set; } + + [JsonPropertyName("multiple")] + public bool Multiple { get; set; } + + [JsonPropertyName("default_value")] + public string DefaultValue { get; set; } + + [JsonPropertyName("visible")] + public bool Visible { get; set; } + + [JsonPropertyName("possible_values")] + public List PossibleValues { get; set; } + + [JsonPropertyName("trackers")] + public List Trackers { get; set; } + + [JsonPropertyName("roles")] + public List Roles { get; set; } + } + + public class PossibleValue + { + [JsonPropertyName("value")] + public string Value { get; set; } + + [JsonPropertyName("label")] + public string Label { get; set; } + } + + public class Role + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Root + { + [JsonPropertyName("custom_fields")] + public List CustomFields { get; set; } + } + + public class Tracker + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/Dto/DetailedIssue.cs b/Blueberry.Redmine/Dto/DetailedIssue.cs new file mode 100644 index 0000000..45199c0 --- /dev/null +++ b/Blueberry.Redmine/Dto/DetailedIssue.cs @@ -0,0 +1,197 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class DetailedIssue + { + public class AssignedTo + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Author + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class CustomField + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } + } + + public class Detail + { + [JsonPropertyName("property")] + public string Property { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("old_value")] + public string OldValue { get; set; } + + [JsonPropertyName("new_value")] + public string NewValue { get; set; } + } + + public class Issue + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("project")] + public Project Project { get; set; } + + [JsonPropertyName("tracker")] + public Tracker Tracker { get; set; } + + [JsonPropertyName("status")] + public Status Status { get; set; } + + [JsonPropertyName("priority")] + public Priority Priority { get; set; } + + [JsonPropertyName("author")] + public Author Author { get; set; } + + [JsonPropertyName("assigned_to")] + public AssignedTo AssignedTo { get; set; } + + [JsonPropertyName("subject")] + public string Subject { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("start_date")] + public string StartDate { get; set; } + + [JsonPropertyName("due_date")] + public object DueDate { get; set; } + + [JsonPropertyName("done_ratio")] + public int DoneRatio { get; set; } + + [JsonPropertyName("is_private")] + public bool IsPrivate { get; set; } + + [JsonPropertyName("estimated_hours")] + public object EstimatedHours { get; set; } + + [JsonPropertyName("total_estimated_hours")] + public object TotalEstimatedHours { get; set; } + + [JsonPropertyName("spent_hours")] + public double SpentHours { get; set; } + + [JsonPropertyName("total_spent_hours")] + public double TotalSpentHours { get; set; } + + [JsonPropertyName("custom_fields")] + public List CustomFields { get; set; } + + [JsonPropertyName("created_on")] + public DateTime CreatedOn { get; set; } + + [JsonPropertyName("updated_on")] + public DateTime UpdatedOn { get; set; } + + [JsonPropertyName("closed_on")] + public object ClosedOn { get; set; } + + [JsonPropertyName("journals")] + public List Journals { get; set; } + } + + public class Journal + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("user")] + public User User { get; set; } + + [JsonPropertyName("notes")] + public string Notes { get; set; } + + [JsonPropertyName("created_on")] + public DateTime CreatedOn { get; set; } + + [JsonPropertyName("private_notes")] + public bool PrivateNotes { get; set; } + + [JsonPropertyName("details")] + public List Details { get; set; } + } + + public class Priority + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Project + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Root + { + [JsonPropertyName("issue")] + public Issue Issue { get; set; } + } + + public class Status + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Tracker + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class User + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/Dto/IResponseList.cs b/Blueberry.Redmine/Dto/IResponseList.cs new file mode 100644 index 0000000..1a4c84c --- /dev/null +++ b/Blueberry.Redmine/Dto/IResponseList.cs @@ -0,0 +1,9 @@ +namespace Blueberry.Redmine.Dto +{ + public interface IResponseList + { + public int TotalCount { get; set; } + public int Offset { get; set; } + public int Limit { get; set; } + } +} diff --git a/Blueberry.Redmine/Dto/IssueList.cs b/Blueberry.Redmine/Dto/IssueList.cs new file mode 100644 index 0000000..de47608 --- /dev/null +++ b/Blueberry.Redmine/Dto/IssueList.cs @@ -0,0 +1,183 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class IssueList + { + public class AssignedTo + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Author + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class CustomField + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } + } + + public class Issue + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("project")] + public Project Project { get; set; } + public string ProjectName => Project.Name; + + [JsonPropertyName("tracker")] + public Tracker Tracker { get; set; } + + [JsonPropertyName("status")] + public Status Status { get; set; } + public string StatusName => Status.Name; + + [JsonPropertyName("priority")] + public Priority Priority { get; set; } + public string PriorityName => Priority.Name; + + [JsonPropertyName("author")] + public Author Author { get; set; } + + [JsonPropertyName("assigned_to")] + public AssignedTo AssignedTo { get; set; } + + [JsonPropertyName("subject")] + public string Subject { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("start_date")] + public string StartDate { get; set; } + + [JsonPropertyName("due_date")] + public string DueDate { get; set; } + + [JsonPropertyName("done_ratio")] + public int DoneRatio { get; set; } + + [JsonPropertyName("is_private")] + public bool IsPrivate { get; set; } + + [JsonPropertyName("estimated_hours")] + public double? EstimatedHours { get; set; } + + [JsonPropertyName("custom_fields")] + public List CustomFields { get; set; } + + [JsonPropertyName("created_on")] + public DateTime CreatedOn { get; set; } + + [JsonPropertyName("updated_on")] + public DateTime UpdatedOn { get; set; } + public string LastUpdate + { + get + { + var span = DateTime.Now - UpdatedOn; + + if (span.TotalMinutes < 1) return "épp most"; + if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes} perce"; + if (span.TotalHours < 24) return $"{(int)span.TotalHours} órája"; + if (span.TotalDays < 7) return $"{(int)span.TotalDays} napja"; + if (span.TotalDays < 30) return $"{(int)(span.TotalDays / 7)} hete"; + if (span.TotalDays < 365) return $"{(int)(span.TotalDays / 30)} hónapja"; + + return $"{(int)(span.TotalDays / 365)} éve"; + } + } + + [JsonPropertyName("closed_on")] + public DateTime? ClosedOn { get; set; } + + [JsonPropertyName("parent")] + public Parent Parent { get; set; } + } + + public class Parent + { + [JsonPropertyName("id")] + public int Id { get; set; } + } + + public class Priority + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Project + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class SimpleRoot + { + [JsonPropertyName("issue")] + public Issue Issue { get; set; } + } + + public class Root : IResponseList + { + [JsonPropertyName("issues")] + public List Issues { get; set; } + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } + + [JsonPropertyName("offset")] + public int Offset { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + } + + public class Status + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Tracker + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/Dto/NewIssue.cs b/Blueberry.Redmine/Dto/NewIssue.cs new file mode 100644 index 0000000..2fa9dd9 --- /dev/null +++ b/Blueberry.Redmine/Dto/NewIssue.cs @@ -0,0 +1,131 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class NewIssue + { + public class Author + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class CustomField + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } + } + + public class Issue + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("project")] + public Project Project { get; set; } + + [JsonPropertyName("tracker")] + public Tracker Tracker { get; set; } + + [JsonPropertyName("status")] + public Status Status { get; set; } + + [JsonPropertyName("priority")] + public Priority Priority { get; set; } + + [JsonPropertyName("author")] + public Author Author { get; set; } + + [JsonPropertyName("subject")] + public string Subject { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("start_date")] + public string StartDate { get; set; } + + [JsonPropertyName("due_date")] + public object DueDate { get; set; } + + [JsonPropertyName("done_ratio")] + public int DoneRatio { get; set; } + + [JsonPropertyName("is_private")] + public bool IsPrivate { get; set; } + + [JsonPropertyName("estimated_hours")] + public double EstimatedHours { get; set; } + + [JsonPropertyName("total_estimated_hours")] + public double TotalEstimatedHours { get; set; } + + [JsonPropertyName("custom_fields")] + public List CustomFields { get; set; } + + [JsonPropertyName("created_on")] + public DateTime CreatedOn { get; set; } + + [JsonPropertyName("updated_on")] + public DateTime UpdatedOn { get; set; } + + [JsonPropertyName("closed_on")] + public object ClosedOn { get; set; } + } + + public class Priority + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Project + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Root + { + [JsonPropertyName("issue")] + public Issue Issue { get; set; } + } + + public class Status + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Tracker + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/Dto/PriorityList.cs b/Blueberry.Redmine/Dto/PriorityList.cs new file mode 100644 index 0000000..2649822 --- /dev/null +++ b/Blueberry.Redmine/Dto/PriorityList.cs @@ -0,0 +1,32 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class PriorityList + { + public class IssuePriority + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("is_default")] + public bool IsDefault { get; set; } + + [JsonPropertyName("active")] + public bool Active { get; set; } + } + + public class Root + { + [JsonPropertyName("issue_priorities")] + public List IssuePriorities { get; set; } + } + + + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/Dto/ProjectList.cs b/Blueberry.Redmine/Dto/ProjectList.cs new file mode 100644 index 0000000..69e0e66 --- /dev/null +++ b/Blueberry.Redmine/Dto/ProjectList.cs @@ -0,0 +1,83 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class ProjectList + { + public class CustomField + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } + } + + public class Parent + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Project + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("identifier")] + public string Identifier { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("is_public")] + public bool IsPublic { get; set; } + + [JsonPropertyName("inherit_members")] + public bool InheritMembers { get; set; } + + [JsonPropertyName("custom_fields")] + public List CustomFields { get; set; } + + [JsonPropertyName("created_on")] + public DateTime CreatedOn { get; set; } + + [JsonPropertyName("updated_on")] + public DateTime UpdatedOn { get; set; } + + [JsonPropertyName("parent")] + public Parent Parent { get; set; } + } + + public class Root : IResponseList + { + [JsonPropertyName("projects")] + public List Projects { get; set; } + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } + + [JsonPropertyName("offset")] + public int Offset { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + } + + + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/Dto/ProjectTrackers.cs b/Blueberry.Redmine/Dto/ProjectTrackers.cs new file mode 100644 index 0000000..54d2230 --- /dev/null +++ b/Blueberry.Redmine/Dto/ProjectTrackers.cs @@ -0,0 +1,89 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class ProjectTrackers + { + public class CustomField + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } + } + + public class Parent + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Project + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("identifier")] + public string Identifier { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("homepage")] + public string Homepage { get; set; } + + [JsonPropertyName("parent")] + public Parent Parent { get; set; } + + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("is_public")] + public bool IsPublic { get; set; } + + [JsonPropertyName("inherit_members")] + public bool InheritMembers { get; set; } + + [JsonPropertyName("custom_fields")] + public List CustomFields { get; set; } + + [JsonPropertyName("trackers")] + public List Trackers { get; set; } + + [JsonPropertyName("created_on")] + public DateTime CreatedOn { get; set; } + + [JsonPropertyName("updated_on")] + public DateTime UpdatedOn { get; set; } + } + + public class Root + { + [JsonPropertyName("project")] + public Project Project { get; set; } + } + + public class Tracker + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/Dto/StatusList.cs b/Blueberry.Redmine/Dto/StatusList.cs new file mode 100644 index 0000000..1b4f91f --- /dev/null +++ b/Blueberry.Redmine/Dto/StatusList.cs @@ -0,0 +1,29 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class StatusList + { + public class IssueStatus + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("is_closed")] + public bool IsClosed { get; set; } + } + + public class Root + { + [JsonPropertyName("issue_statuses")] + public List IssueStatuses { get; set; } + } + + + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/Dto/UserInfo.cs b/Blueberry.Redmine/Dto/UserInfo.cs new file mode 100644 index 0000000..48b2ce2 --- /dev/null +++ b/Blueberry.Redmine/Dto/UserInfo.cs @@ -0,0 +1,74 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class UserInfo + { + public class CustomField + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } + } + + public class Root + { + [JsonPropertyName("user")] + public User User { get; set; } + } + + public class User + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("login")] + public string Login { get; set; } + + [JsonPropertyName("admin")] + public bool Admin { get; set; } + + [JsonPropertyName("firstname")] + public string Firstname { get; set; } + + [JsonPropertyName("lastname")] + public string Lastname { get; set; } + + [JsonPropertyName("mail")] + public string Mail { get; set; } + + [JsonPropertyName("created_on")] + public DateTime CreatedOn { get; set; } + + [JsonPropertyName("updated_on")] + public DateTime UpdatedOn { get; set; } + + [JsonPropertyName("last_login_on")] + public DateTime LastLoginOn { get; set; } + + [JsonPropertyName("passwd_changed_on")] + public DateTime PasswdChangedOn { get; set; } + + [JsonPropertyName("twofa_scheme")] + public object TwofaScheme { get; set; } + + [JsonPropertyName("api_key")] + public string ApiKey { get; set; } + + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("custom_fields")] + public List CustomFields { get; set; } + } + + + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/Dto/UserTime.cs b/Blueberry.Redmine/Dto/UserTime.cs new file mode 100644 index 0000000..06177e8 --- /dev/null +++ b/Blueberry.Redmine/Dto/UserTime.cs @@ -0,0 +1,107 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class UserTime + { + public class Activity + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class CustomField + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } + } + + public class Issue + { + [JsonPropertyName("id")] + public int Id { get; set; } + } + + public class Project + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + public class Root : IResponseList + { + [JsonPropertyName("time_entries")] + public List TimeEntries { get; set; } + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } + + [JsonPropertyName("offset")] + public int Offset { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + } + + public class TimeEntry + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("project")] + public Project Project { get; set; } + + [JsonPropertyName("issue")] + public Issue Issue { get; set; } + + [JsonPropertyName("user")] + public User User { get; set; } + + [JsonPropertyName("activity")] + public Activity Activity { get; set; } + + [JsonPropertyName("hours")] + public double Hours { get; set; } + + [JsonPropertyName("comments")] + public string Comments { get; set; } + + [JsonPropertyName("spent_on")] + public string SpentOn { get; set; } + + [JsonPropertyName("created_on")] + public DateTime CreatedOn { get; set; } + + [JsonPropertyName("updated_on")] + public DateTime UpdatedOn { get; set; } + + [JsonPropertyName("custom_fields")] + public List CustomFields { get; set; } + } + + public class User + { + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + } + + + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/RedmineApiClient.cs b/Blueberry.Redmine/RedmineApiClient.cs new file mode 100644 index 0000000..f46e562 --- /dev/null +++ b/Blueberry.Redmine/RedmineApiClient.cs @@ -0,0 +1,282 @@ +using Blueberry.Redmine.Dto; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using static Blueberry.Redmine.Dto.UserTime; + +namespace Blueberry.Redmine +{ + public class RedmineApiClient + { + private const int RETRY_DELAY_MS = 2000; + private const int PAGING_LIMIT = 50; + private readonly RedmineConfig _config; + private readonly JsonSerializerOptions _jsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + private readonly ILogger _logger; + readonly HttpClient _httpClient; + + public RedmineApiClient(RedmineConfig config, ILogger logger, HttpClient httpClient) + { + _config = config; + _logger = logger; + _httpClient = httpClient; + } + + private async Task SendRequestAsync(HttpMethod method, string endpoint, object? payload = null, CancellationToken? token = null) + { + string url = $"{_config.RedmineUrl}/{endpoint}"; + int maxRetries = _config.MaxRetries; + CancellationToken cancellationToken = token ?? CancellationToken.None; + + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + using var request = new HttpRequestMessage(method, url); + request.Headers.Add("X-Redmine-API-Key", _config.ApiKey); + + if (payload != null) + { + string json = JsonSerializer.Serialize(payload, _jsonOptions); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + if (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Request cancelled by token"); + cancellationToken.ThrowIfCancellationRequested(); + } + + var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + if (response.Content.Headers.ContentLength == 0) + return default; + + var responseStream = await response.Content.ReadAsStreamAsync(); + return await JsonSerializer.DeserializeAsync(responseStream, _jsonOptions); + } + + bool isServerError = (int)response.StatusCode >= 500 && (int)response.StatusCode < 600; + + if (isServerError && attempt < maxRetries) + { + _logger.LogWarning("Received {StatusCode} from Redmine. Retrying in {Delay}ms (Attempt {Attempt} of {MaxRetries})", response.StatusCode, RETRY_DELAY_MS, attempt + 1, maxRetries); + + response.Dispose(); + + await Task.Delay(RETRY_DELAY_MS).ConfigureAwait(false); + continue; + } + + string errorBody = await response.Content.ReadAsStringAsync(); + response.Dispose(); + + _logger.LogError("Error ({StatusCode}): {ErrorBody}", response.StatusCode, errorBody); + throw new RedmineApiException($"Redmine API Error {response.StatusCode}: {errorBody}", (int)response.StatusCode); + } + + throw new RedmineApiException("Redmine API Unreachable"); + } + + private async Task> SendRequestWithPagingAsync(HttpMethod method, string endpoint, int limit, Func> itemParser, + IProgress<(int current, int total)>? progress = null, object? payload = null, CancellationToken? token = null) where TResponse : IResponseList + { + var offset = 0; + + List returnList = []; + + while (true) + { + var path = $"{endpoint}&limit={limit}&offset={offset}"; + + var responseList = await SendRequestAsync(HttpMethod.Get, path, token: token) + ?? throw new NullReferenceException(); + + returnList.AddRange(itemParser(responseList)); + + if (offset + limit >= responseList.TotalCount) + break; + + offset += limit; + if(progress != null) + { + var current = Math.Min(offset + limit, responseList.TotalCount); + progress.Report((current, responseList.TotalCount)); + } + } + + return returnList; + } + + public async Task> GetStatusesAsync(CancellationToken? token = null) + { + var path = "issue_statuses.json"; + + var statusList = await SendRequestAsync(HttpMethod.Get, path, token: token) + ?? throw new NullReferenceException(); + + return statusList.IssueStatuses; + } + + public async Task> GetCustomFieldsAsync(CancellationToken? token = null) + { + var path = "custom_fields.json"; + + var fields = await SendRequestAsync(HttpMethod.Get, path, token: token) + ?? throw new NullReferenceException(); + + return fields.CustomFields; + } + + public async Task> GetPrioritiesAsync(CancellationToken? token = null) + { + var path = "enumerations/issue_priorities.json"; + + var fields = await SendRequestAsync(HttpMethod.Get, path, token: token) + ?? throw new NullReferenceException(); + + return fields.IssuePriorities; + } + + public async Task> GetOpenIssuesByAssignee(int userId, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken ? token = null) + { + var path = $"issues.json?assigned_to_id={userId}&status_id=open"; + + var items = await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x) => x.Issues, + progress, token: token); + + return items; + } + + public async Task> GetProjects(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + var path = $"projects.json"; + + var items = await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x) => x.Projects, + progress, token: token); + + return items; + } + + public async Task> GetTrackersForProject(string projectId, CancellationToken? token = null) + { + var path = $"projects/{projectId}.json?include=trackers"; + + var trackers = await SendRequestAsync(HttpMethod.Get, path, token: token) + ?? throw new NullReferenceException(); + + return trackers.Project.Trackers; + } + + public async Task GetTotalTimeForUser(int userId, DateTime start, DateTime end, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + var sText = start.ToString("yyyy-MM-dd"); + var eText = end.ToString("yyyy-MM-dd"); + + var path = $"time_entries.json?from={sText}&to={eText}&user_id={userId}"; + + + var timedata = await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x)=> x.TimeEntries, progress, token: token); + + var hours = timedata.Sum(x => x.Hours); + + return hours; + } + + public async Task GetIssue(int issueId, CancellationToken? token = null) + { + var path = $"issues/{issueId}.json?include=journals"; + + var issue = await SendRequestAsync(HttpMethod.Get, path, token: token) + ?? throw new NullReferenceException(); + + return issue.Issue; + } + + public async Task GetSimpleIssue(int issueId, CancellationToken? token = null) + { + var path = $"issues/{issueId}.json?include=journals"; + + var issue = await SendRequestAsync(HttpMethod.Get, path, token: token) + ?? throw new NullReferenceException(); + + return issue.Issue; + } + + public async Task GetUserAsync(int? userId = null, CancellationToken? token = null) + { + var path = "users/current.json"; + + if (userId != null) + path = $"users/{userId}.json"; + + var user = await SendRequestAsync(HttpMethod.Get, path, token: token) + ?? throw new NullReferenceException(); + + return user.User; + } + + public async Task SetIssueStatus(int issueId, int statusId, CancellationToken? token = null) + { + var path = $"issues/{issueId}.json"; + + var payload = new + { + issue = new + { + status_id = statusId + } + }; + + await SendRequestAsync(HttpMethod.Put, path, payload, token: token); + } + + public async Task CreateNewIssue(int projectId, int trackerId, string subject, string description, + double estimatedHours, int priorityId, int? assigneeId = null, int? parentIssueId = null, CancellationToken? token = null) + { + var path = "issues.json"; + + var payload = new + { + project_id = projectId, + tracker_id = trackerId, + subject = subject, + description = description, + priority_id = priorityId, + assigned_to_id = assigneeId, + parent_issue_id = parentIssueId, + custom_fields = new // TODO, do something about this + { + id = 65, + value = estimatedHours + } + }; + + var issue = await SendRequestAsync(HttpMethod.Post, path, payload, token) + ?? throw new NullReferenceException(); + + return issue.Issue.Id; + } + + public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null, CancellationToken? token = null) + { + string url = $"time_entries.json"; + string dateString = (date ?? DateTime.Now).ToString("yyyy-MM-dd"); + + var payload = new + { + time_entry = new + { + issue_id = issueId, + hours = hours, + comments = comments, + spent_on = dateString, + activity_id = activityId + } + }; + + await SendRequestAsync(HttpMethod.Post, url, payload, token: token); + } + } +} diff --git a/Blueberry/Redmine/RedmineApiException.cs b/Blueberry.Redmine/RedmineApiException.cs similarity index 94% rename from Blueberry/Redmine/RedmineApiException.cs rename to Blueberry.Redmine/RedmineApiException.cs index 4f620a8..4083259 100644 --- a/Blueberry/Redmine/RedmineApiException.cs +++ b/Blueberry.Redmine/RedmineApiException.cs @@ -1,4 +1,4 @@ -namespace BlueMine.Redmine +namespace Blueberry.Redmine { public class RedmineApiException : Exception { diff --git a/Blueberry.Redmine/RedmineCache.cs b/Blueberry.Redmine/RedmineCache.cs new file mode 100644 index 0000000..c34a702 --- /dev/null +++ b/Blueberry.Redmine/RedmineCache.cs @@ -0,0 +1,208 @@ +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Blueberry.Redmine +{ + class RedmineCache + { + private List Items { get; set; } = []; + + public DateTime LastUpdated { get; set; } = DateTime.MinValue; + private readonly ILogger _logger; + private readonly TimeSpan _cacheDuration; + private readonly object _lock = new(); + private int _maxCapacity = int.MaxValue; + private bool _useSlidingExpiration = false; + private DateTime _lastAccessed = DateTime.MinValue; + private string? _cacheFilePath; + private Func>>? _refreshCallback; + + private class CacheData + { + public List Items { get; set; } = []; + public DateTime LastUpdated { get; set; } + public DateTime LastAccessed { get; set; } + } + + /// + /// Initializes a new instance of the RedmineCache class. + /// + /// The time span for which the cache remains valid. After this duration, the cache is considered expired. + /// The logger instance for logging cache operations. + /// The maximum number of items to store in the cache. Defaults to int.MaxValue (unlimited). + /// If true, the cache expiration resets on each access (sliding expiration). If false, uses absolute expiration from the last update. Defaults to false. + /// Optional file path for persisting the cache to disk. If provided, the cache loads from and saves to this file. Defaults to null (no persistence). + /// Optional asynchronous callback function to refresh the cache when it expires. Called automatically in GetItemsAsync if the cache is invalid. Defaults to null. + public RedmineCache(TimeSpan cacheDuration, ILogger> logger, int maxCapacity = int.MaxValue, bool useSlidingExpiration = false, + string? cacheFilePath = null, Func>>? refreshCallback = null) + { + if (logger == null) throw new ArgumentNullException(nameof(logger)); + if (cacheDuration <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(cacheDuration)); + + _logger = logger; + _cacheDuration = cacheDuration; + _maxCapacity = maxCapacity; + _useSlidingExpiration = useSlidingExpiration; + _cacheFilePath = cacheFilePath; + _refreshCallback = refreshCallback; + + LoadFromFile(); + } + + /// + /// Initializes a new instance of the RedmineCache class with cache duration in seconds. + /// + /// The cache duration in seconds. Converted to a TimeSpan internally. + /// The logger instance for logging cache operations. + /// The maximum number of items to store in the cache. Defaults to int.MaxValue (unlimited). + /// If true, the cache expiration resets on each access (sliding expiration). If false, uses absolute expiration from the last update. Defaults to false. + /// Optional file path for persisting the cache to disk. If provided, the cache loads from and saves to this file. Defaults to null (no persistence). + /// Optional asynchronous callback function to refresh the cache when it expires. Called automatically in GetItemsAsync if the cache is invalid. Defaults to null. + public RedmineCache(int cacheDurationSec, ILogger> logger, int maxCapacity = int.MaxValue, bool useSlidingExpiration = false, + string? cacheFilePath = null, Func>>? refreshCallback = null) + : this(new TimeSpan(0, 0, cacheDurationSec), logger, maxCapacity, useSlidingExpiration, cacheFilePath, refreshCallback) { } + + private void LoadFromFile() + { + if (_cacheFilePath == null || !File.Exists(_cacheFilePath)) return; + + try + { + var json = File.ReadAllText(_cacheFilePath); + var data = JsonSerializer.Deserialize(json); + if (data != null) + { + Items = data.Items ?? []; + LastUpdated = data.LastUpdated; + _lastAccessed = data.LastAccessed; + } + _logger.LogDebug("Loaded cache from {path}", _cacheFilePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load cache from {path}", _cacheFilePath); + } + } + + private void SaveToFile() + { + if (_cacheFilePath == null) return; + + try + { + var data = new CacheData { Items = Items, LastUpdated = LastUpdated, LastAccessed = _lastAccessed }; + var json = JsonSerializer.Serialize(data); + var dir = Path.GetDirectoryName(_cacheFilePath) ?? throw new NullReferenceException(); + Directory.CreateDirectory(dir); + File.WriteAllText(_cacheFilePath, json); + _logger.LogDebug("Saved cache to {path}", _cacheFilePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save cache to {path}", _cacheFilePath); + } + } + + private void RefreshCache(List newItems) + { + _logger.LogDebug("Refreshing cache with {count} items", newItems.Count); + lock (_lock) + { + Items = newItems.Count > _maxCapacity ? newItems.Take(_maxCapacity).ToList() : newItems; + LastUpdated = DateTime.UtcNow; + _lastAccessed = DateTime.UtcNow; + SaveToFile(); + _logger.LogDebug("Cache refreshed"); + } + } + + public void InvalidateCache() + { + _logger.LogDebug("Invalidating cache"); + lock (_lock) + { + LastUpdated = DateTime.MinValue; + _lastAccessed = DateTime.MinValue; + Items.Clear(); + SaveToFile(); + _logger.LogDebug("Cache invalidated"); + } + } + + public bool IsCacheValid() + { + lock (_lock) + { + bool valid = !_useSlidingExpiration ? DateTime.UtcNow - LastUpdated <= _cacheDuration : DateTime.UtcNow - _lastAccessed <= _cacheDuration; + _logger.LogDebug("Cache valid: {valid}", valid); + return valid; + } + } + + private IReadOnlyList GetItems() + { + lock (_lock) + { + _lastAccessed = DateTime.UtcNow; + _logger.LogDebug("Returning {count} cached items", Items.Count); + return Items.AsReadOnly(); + } + } + + public Task RefreshCacheAsync(List newItems) => Task.Run(() => RefreshCache(newItems)); + + public async Task> GetItemsAsync() + { + bool needsRefresh = false; + lock (_lock) + { + _lastAccessed = DateTime.UtcNow; + if (!IsCacheValid() && _refreshCallback != null) + { + needsRefresh = true; + } + } + if (needsRefresh) + { + await TryRefreshAsync(); + } + lock (_lock) + { + _logger.LogDebug("Returning {count} cached items", Items.Count); + return Items.AsReadOnly(); + } + } + + public bool IsEmpty() + { + lock (_lock) + { + return Items.Count == 0; + } + } + + public int GetCount() + { + lock (_lock) + { + return Items.Count; + } + } + + public async Task TryRefreshAsync() + { + if (_refreshCallback != null) + { + try + { + var newItems = await _refreshCallback(); + RefreshCache(newItems); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh cache via callback"); + } + } + } + } +} diff --git a/Blueberry/Redmine/RedmineConfig.cs b/Blueberry.Redmine/RedmineConfig.cs similarity index 64% rename from Blueberry/Redmine/RedmineConfig.cs rename to Blueberry.Redmine/RedmineConfig.cs index 31422db..ef41afb 100644 --- a/Blueberry/Redmine/RedmineConfig.cs +++ b/Blueberry.Redmine/RedmineConfig.cs @@ -1,4 +1,4 @@ -namespace BlueMine.Redmine +namespace Blueberry.Redmine { public class RedmineConfig { @@ -8,5 +8,7 @@ namespace BlueMine.Redmine public TimeSpan IssueCacheDuration { get; set; } = TimeSpan.FromMinutes(5); public int MaxRetries { get; set; } = 3; public int ConcurrencyLimit { get; set; } = 10; + public string CacheFilePath { get; set; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Blueberry", "Cache", "Redmine"); + public bool IsInitiating { get; set; } = false; } } \ No newline at end of file diff --git a/Blueberry.Redmine/RedmineManager.cs b/Blueberry.Redmine/RedmineManager.cs new file mode 100644 index 0000000..cd5fdc8 --- /dev/null +++ b/Blueberry.Redmine/RedmineManager.cs @@ -0,0 +1,167 @@ +using Blueberry.Redmine.Dto; +using Microsoft.Extensions.Logging; + +namespace Blueberry.Redmine +{ + public class RedmineManager + { + private readonly TimeSpan DEFAULT_CACHE_DURATION = TimeSpan.FromHours(1); + private readonly RedmineConfig _config; + private readonly ILogger _logger; + private readonly RedmineApiClient _apiClient; + private readonly RedmineCache _statusCache; + private readonly RedmineCache _priorityCache; + private readonly RedmineCache _customFieldCache; + private readonly RedmineCache _projectCache; + + public RedmineManager(RedmineConfig config, HttpClient client, ILoggerFactory loggerFactory) + { + _config = config; + _apiClient = new RedmineApiClient(config, loggerFactory.CreateLogger(), client); + + _statusCache = new RedmineCache( + DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger>(), cacheFilePath: $"{_config.CacheFilePath}Statuses.json"); + + _priorityCache = new RedmineCache( + DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger>(), cacheFilePath: $"{_config.CacheFilePath}Priorities.json"); + + _customFieldCache = new RedmineCache( + DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger>(), cacheFilePath: $"{_config.CacheFilePath}CustomFields.json"); + + _projectCache = new RedmineCache( + DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger>(), cacheFilePath: $"{_config.CacheFilePath}Projects.json"); + + _logger = loggerFactory.CreateLogger(); + _logger.LogDebug("Initialized caches"); + } + + public async Task IsRedmineAvailable(CancellationToken? token = null) + { + try + { + await _apiClient.GetUserAsync(); + return true; + } + catch (Exception) + { + return false; + } + } + + public async Task> GetStatusesAsync(CancellationToken? token = null) + { + if (_statusCache.IsCacheValid()) + { + return await _statusCache.GetItemsAsync(); + } + var statuses = await _apiClient.GetStatusesAsync(token); + await _statusCache.RefreshCacheAsync(statuses); + return statuses; + } + + public async Task> GetPrioritiesAsync(CancellationToken? token = null) + { + if (_priorityCache.IsCacheValid()) + { + return await _priorityCache.GetItemsAsync(); + } + var priorities = await _apiClient.GetPrioritiesAsync(token); + await _priorityCache.RefreshCacheAsync(priorities); + return priorities; + } + + public async Task> GetCustomFieldsAsync(CancellationToken? token = null) + { + if (_customFieldCache.IsCacheValid()) + { + return await _customFieldCache.GetItemsAsync(); + } + var fields = await _apiClient.GetCustomFieldsAsync(token); + await _customFieldCache.RefreshCacheAsync(fields); + return fields; + } + + public async Task> GetProjectsAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + if (_projectCache.IsCacheValid()) + { + return await _projectCache.GetItemsAsync(); + } + var projects = await _apiClient.GetProjects(limit, progress, token); + await _projectCache.RefreshCacheAsync(projects); + return projects; + } + + public async Task GetCurrentUserAsync(CancellationToken? token = null) + { + var user = await _apiClient.GetUserAsync(token: token); + return user; + } + + public async Task GetUserAsync(int userId, CancellationToken? token = null) + { + var user = await _apiClient.GetUserAsync(userId, token: token); + return user; + } + + public async Task> GetCurrentUserIssuesAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + var user = await GetCurrentUserAsync(token); + return await _apiClient.GetOpenIssuesByAssignee(user.Id, limit, progress, token); + } + + public async Task GetCurrentUserTimeAsync(DateTime start, DateTime end, int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + var user = await GetCurrentUserAsync(token); + return await _apiClient.GetTotalTimeForUser(user.Id, start, end, limit, progress, token); + } + + public async Task GetIssueAsync(int issueId, CancellationToken? token = null) + { + return await _apiClient.GetIssue(issueId, token); + } + + public async Task GetSimpleIssueAsync(int issueId, CancellationToken? token = null) + { + return await _apiClient.GetSimpleIssue(issueId, token); + } + + public async Task> GetProjectTrackersAsync(int projectId, CancellationToken? token = null) + { + return await _apiClient.GetTrackersForProject(projectId.ToString(), token); + } + + public async Task SetIssueStatusAsync(int issueId, int statusId, CancellationToken? token = null) + { + await _apiClient.SetIssueStatus(issueId, statusId, token); + } + + public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null, CancellationToken? token = null) + { + await _apiClient.LogTimeAsync(issueId, hours, comments, date, activityId, token); + } + + public async Task CreateIssueAsync(int projectId, int trackerId, string subject, string description, + double estimatedHours, int priorityId, int? assigneeId = null, int? parentIssueId = null, CancellationToken? token = null) + { + return await _apiClient.CreateNewIssue(projectId, trackerId, subject, description, estimatedHours, priorityId, assigneeId, parentIssueId, token); + } + + public async Task GetCurrentUserTimeTodayAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + return await GetCurrentUserTimeAsync(DateTime.Today, DateTime.Today, limit, progress, token); + } + + public async Task GetCurrentUserTimeYesterdayAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + return await GetCurrentUserTimeAsync(DateTime.Today.AddDays(-1), DateTime.Today.AddDays(-1), limit, progress, token); + } + + public async Task GetCurrentUserTimeThisMonthAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + var start = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); + var end = start.AddMonths(1).AddDays(-1); + return await GetCurrentUserTimeAsync(start, end, limit, progress, token); + } + } +} diff --git a/Blueberry/SettingsManager.cs b/Blueberry.Redmine/RedmineSettingsManager.cs similarity index 77% rename from Blueberry/SettingsManager.cs rename to Blueberry.Redmine/RedmineSettingsManager.cs index 5c251f4..f186453 100644 --- a/Blueberry/SettingsManager.cs +++ b/Blueberry.Redmine/RedmineSettingsManager.cs @@ -1,16 +1,11 @@ using System.Text; -using BlueMine.Redmine; -using System.IO; using System.Security.Cryptography; // For encryption using System.Text.Json; -namespace BlueMine +namespace Blueberry.Redmine { - - - public class SettingsManager + public class RedmineSettingsManager { - // Save to: C:\Users\Username\AppData\Roaming\YourAppName\settings.json private readonly string _filePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Blueberry", @@ -19,7 +14,10 @@ namespace BlueMine public RedmineConfig Load() { if (!File.Exists(_filePath)) - return new RedmineConfig(); + return new RedmineConfig() + { + IsInitiating = true, + }; try { @@ -28,7 +26,6 @@ namespace BlueMine if(config == null) return new RedmineConfig(); - // Decrypt the API Key if it exists if (!string.IsNullOrEmpty(config.ApiKey)) { config.ApiKey = Unprotect(config.ApiKey); @@ -37,34 +34,30 @@ namespace BlueMine } catch { - // If file is corrupted, return default return new RedmineConfig(); } } public void Save(RedmineConfig config) { - // Create directory if it doesn't exist Directory.CreateDirectory(Path.GetDirectoryName(_filePath) ?? throw new NullReferenceException("Config directory path creation failed.")); - // Create a copy to encrypt so we don't mess up the runtime object var copy = new RedmineConfig { RedmineUrl = config.RedmineUrl, - // Encrypt the key before saving ApiKey = Protect(config.ApiKey), ProjectCacheDuration = config.ProjectCacheDuration, - // ... copy other fields + CacheFilePath = config.CacheFilePath, + ConcurrencyLimit = config.ConcurrencyLimit, + IssueCacheDuration = config.IssueCacheDuration, + MaxRetries = config.MaxRetries, + IsInitiating = false }; var json = JsonSerializer.Serialize(copy, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(_filePath, json); } - // --- ENCRYPTION HELPERS (DPAPI) --- - // This encrypts data using the current user's Windows credentials. - // Only this user on this machine can decrypt it. - private string Protect(string clearText) { if (string.IsNullOrEmpty(clearText)) return ""; diff --git a/Blueberry.sln b/Blueberry.sln index 3594254..6c24c3c 100644 --- a/Blueberry.sln +++ b/Blueberry.sln @@ -1,11 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36603.0 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11304.174 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blueberry", "Blueberry\Blueberry.csproj", "{201018E0-4328-4B0A-8BD7-0E3AC6155A68}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlueberryUpdater", "BlueberryUpdater\BlueberryUpdater.csproj", "{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blueberry.Redmine", "Blueberry.Redmine\Blueberry.Redmine.csproj", "{54CFDEC5-3E7B-4B9F-B76C-B0E742729624}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -17,10 +17,10 @@ Global {201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Debug|Any CPU.Build.0 = Debug|Any CPU {201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Release|Any CPU.ActiveCfg = Release|Any CPU {201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Release|Any CPU.Build.0 = Release|Any CPU - {3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Release|Any CPU.Build.0 = Release|Any CPU + {54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Blueberry/App.xaml.cs b/Blueberry/App.xaml.cs index ed8c930..2e83d4d 100644 --- a/Blueberry/App.xaml.cs +++ b/Blueberry/App.xaml.cs @@ -1,4 +1,5 @@ -using BlueMine.Redmine; +using Blueberry; +using Blueberry.Redmine; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -23,11 +24,11 @@ namespace BlueMine .ConfigureAppConfiguration(c => { c.SetBasePath(Path.GetDirectoryName(AppContext.BaseDirectory) ?? throw new NullReferenceException()); }) .ConfigureServices((context, services) => { - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { - var manager = sp.GetRequiredService(); + var manager = sp.GetRequiredService(); return manager.Load(); }); @@ -52,6 +53,9 @@ namespace BlueMine /// private async void OnStartup(object sender, StartupEventArgs e) { + var update = new UpdateManager(); + await update.CheckAndInstallAsync(); + await _host.StartAsync(); var mainWindow = _host.Services.GetRequiredService(); mainWindow.Show(); diff --git a/Blueberry/Blueberry.csproj b/Blueberry/Blueberry.csproj index 6ad1072..817ab66 100644 --- a/Blueberry/Blueberry.csproj +++ b/Blueberry/Blueberry.csproj @@ -40,4 +40,8 @@ + + + + diff --git a/Blueberry/Constants.cs b/Blueberry/Constants.cs index f975e29..90f70b9 100644 --- a/Blueberry/Constants.cs +++ b/Blueberry/Constants.cs @@ -1,25 +1,8 @@ -using static BlueMine.Redmine.RedmineDto; - -namespace BlueMine +namespace Blueberry { internal class Constants { - public static IssueItem[] StaticTickets = [ - new IssueItem() { - IssueNumber = 705, - SpentTime = 0, - ProjectName = "OnLiveIT", - IssueName = "Megbeszélés", - IssueDescription = "" - }, - new IssueItem() { - IssueNumber = 801, - SpentTime = 0, - ProjectName = "OnLiveIT", - IssueName = "Egyéb", - IssueDescription = "" - }, - ]; + public static int[] StaticTickets = [705, 801]; public static string[] GenericMessages = [ "Config reszelés", diff --git a/Blueberry/IssueWindow.xaml b/Blueberry/IssueWindow.xaml new file mode 100644 index 0000000..037f223 --- /dev/null +++ b/Blueberry/IssueWindow.xaml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Blueberry/IssueWindow.xaml.cs b/Blueberry/IssueWindow.xaml.cs new file mode 100644 index 0000000..95df93e --- /dev/null +++ b/Blueberry/IssueWindow.xaml.cs @@ -0,0 +1,214 @@ +using Blueberry.Redmine; +using Blueberry.Redmine.Dto; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Windows; +using System.Windows.Media; +using Wpf.Ui.Controls; + +namespace Blueberry +{ + /// + /// Interaction logic for IssueWindow.xaml + /// + public partial class IssueWindow : FluentWindow + { + private readonly DetailedIssue.Issue _issue; + private readonly RedmineManager _manager; + private readonly RedmineConfig _config; + private readonly List _journalDisplays = []; + public ObservableCollection Journals { get; set; } = []; + + public IssueWindow(DetailedIssue.Issue issue, RedmineManager manager, RedmineConfig config) + { + _issue = issue; + _manager = manager; + _config = config; + InitializeComponent(); + DataContext = this; + } + + private async void FluentWindow_Loaded(object sender, RoutedEventArgs e) + { + iSubjectTextBox.Text = _issue.Subject; + iProjectTextBox.Text = _issue.Project.Name; + iDescriptionTextBox.Text = _issue.Description; + iCreatedTextBox.Text = _issue.CreatedOn.ToString("yyyy-MM-dd"); + iUpdatedTextBox.Text = _issue.UpdatedOn.ToString("yyyy-MM-dd"); + iSpentTimeTextBox.Text = _issue.SpentHours.ToString(); + journalProgressRing.Visibility = Visibility.Visible; + _journalDisplays.AddRange(await ProcessJournal(_issue.Journals)); + if(!_journalDisplays.Any(x=>!x.IsData)) + detailsToggleSwitch.IsChecked = true; + await LoadJournal(); + journalProgressRing.Visibility = Visibility.Hidden; + } + + private async Task LoadJournal() + { + var showDetails = detailsToggleSwitch.IsChecked ?? true; + + Journals.Clear(); + foreach (var j in _journalDisplays) + if ((!showDetails && !j.IsData) + || showDetails) + Journals.Add(j); + } + + private async void detailsToggleSwitch_Checked(object sender, RoutedEventArgs e) + { + await LoadJournal(); + } + + private void openBrowserButton_Click(object sender, RoutedEventArgs e) + { + string url = $"{_config.RedmineUrl}/issues/{_issue.Id}"; + + var psi = new ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }; + Process.Start(psi); + } + } + + public partial class IssueWindow + { + public async Task> ProcessJournal(IEnumerable journals) + { + var js = new List(); + + foreach (var item in journals) + { + var user = item.User.Name; + var date = item.CreatedOn.ToString("yyyy-MM-dd HH:mm"); + var content = item.Notes; + if (!string.IsNullOrWhiteSpace(content)) + { + if (item.PrivateNotes) + js.Add(new JournalDisplay + { + User = user, + Date = date, + Content = content, + IsPrivate = true + }); + else + js.Add(new JournalDisplay + { + User = user, + Date = date, + Content = content + }); + } + + foreach (var data in item.Details) + { + try + { + if (data.Property == "attr") + { + switch (data.Name) + { + case "due_date": + var old = data.OldValue ?? "null"; + content = $"Due date:\n{old} > {data.NewValue}"; + js.Add(new JournalDisplay + { + User = user, + Date = date, + Content = content, + IsData = true + }); + break; + case "assigned_to_id": + old = "null"; + if (data.OldValue != null) + { + var u = await _manager.GetUserAsync(int.Parse(data.OldValue)); + old = u.Firstname + " " + u.Lastname; + } + + var newU = await _manager.GetUserAsync(int.Parse(data.NewValue)); + + content = $"Assigned to:\n{old} > {newU.Firstname} {newU.Lastname}"; + js.Add(new JournalDisplay + { + User = user, + Date = date, + Content = content, + IsData = true + }); + break; + case "status_id": + old = "null"; + if (data.OldValue != null) + { + old = (await _manager.GetStatusesAsync()).Where(x => x.Id == int.Parse(data.OldValue)).First().Name; + } + + var newS = (await _manager.GetStatusesAsync()).Where(x => x.Id == int.Parse(data.NewValue)).First().Name; + + content = $"Status changed to:\n{old} > {newS}"; + js.Add(new JournalDisplay + { + User = user, + Date = date, + Content = content, + IsData = true + }); + break; + default: + break; + } + } + if (data.Property == "cf") + { + try + { + if (int.TryParse(data.Name, out var cfId)) + { + var cfs = await _manager.GetCustomFieldsAsync(); + var cfName = cfs.Where(x => x.Id == cfId).First().Name; + var old = data.OldValue ?? "null"; + content = $"{cfName}\n{old} > {data.NewValue}"; + js.Add(new JournalDisplay + { + User = user, + Date = date, + Content = content, + IsData = true + }); + } + } + catch (Exception) { } + } + } catch (Exception) { } + } + } + return js; + } + + public class JournalDisplay + { + public string User { get; set; } + public string Date { get; set; } + public string Content { get; set; } + public bool IsPrivate { get; set; } + public bool IsData { get; set; } + public Brush StatusColor + { + get + { + var resourceKey = IsData ? "TextFillColorTertiaryBrush" : "TextFillColorSecondaryBrush" ; + + // Look up the brush from the App resources + return Application.Current.TryFindResource(resourceKey) as Brush; + } + } + + public SolidColorBrush NameColor => StringToColorConverter.GetColorFromName(User); + } + } +} diff --git a/Blueberry/MainWindow.xaml b/Blueberry/MainWindow.xaml index f1b19f0..3f215bb 100644 --- a/Blueberry/MainWindow.xaml +++ b/Blueberry/MainWindow.xaml @@ -9,9 +9,9 @@ Icon="/bb.ico" Title="Blueberry" Height="720" Width="1280" - MinWidth="650" MinHeight="450" + MinWidth="750" MinHeight="540" d:DataContext="{d:DesignInstance local:MainWindow}" - Loaded="WindowLoaded"> + Loaded="WindowLoaded" WindowStartupLocation="CenterScreen"> /Resources/Roboto.ttf /Resources/Zalando.ttf @@ -35,29 +35,50 @@ - + - - - + + + - - - - + + + + + + + + + - - - - + + + + + + + + + - - - - + + + + + + + + + + + + + + + @@ -73,7 +94,7 @@ - + @@ -85,6 +106,7 @@ + @@ -92,28 +114,25 @@ + Margin="5, 0, 10, 0" Text="{Binding Id}" /> + Text="{Binding Subject}" TextTrimming="CharacterEllipsis" ToolTip="{Binding Subject}" ToolTipService.InitialShowDelay="500" /> - + + Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" Margin="20, 0, 0, 0" Text="{Binding LastUpdate}" + ToolTip="{Binding UpdatedOn}" ToolTipService.InitialShowDelay="200" /> + + + + + - - @@ -156,13 +175,14 @@ + + VerticalAlignment="Stretch" Content="Státusz..." Click="CloseButtonClick" > @@ -173,14 +193,14 @@ - + VerticalAlignment="Stretch" Content="API kulcs..." Click="ApiButtonClicked"> @@ -191,13 +211,37 @@ - - + + + + + + + + + + + + + + + + @@ -210,6 +254,7 @@ - + + diff --git a/Blueberry/MainWindow.xaml.cs b/Blueberry/MainWindow.xaml.cs index 0d24342..84b72d0 100644 --- a/Blueberry/MainWindow.xaml.cs +++ b/Blueberry/MainWindow.xaml.cs @@ -1,11 +1,13 @@ -using BlueMine.Redmine; +using Blueberry; +using Blueberry.Redmine; +using Blueberry.Redmine.Dto; using System.Collections.ObjectModel; using System.Diagnostics; using System.Text.RegularExpressions; +using System.Threading.Tasks; using System.Windows; using System.Windows.Input; using Wpf.Ui.Controls; -using static BlueMine.Redmine.RedmineDto; namespace BlueMine { @@ -15,12 +17,13 @@ namespace BlueMine public partial class MainWindow : FluentWindow { private readonly RedmineManager _manager; - private readonly SettingsManager _settings; + private readonly RedmineSettingsManager _settings; private readonly RedmineConfig _config; - private List _issues = []; - public ObservableCollection IssuesList { get; set; } = []; + private List _issues = []; + public ObservableCollection IssuesList { get; set; } = []; + public ObservableCollection StatusList { get; set; } = []; - public MainWindow(RedmineManager manager, SettingsManager settings, RedmineConfig config) + public MainWindow(RedmineManager manager, RedmineSettingsManager settings, RedmineConfig config) { _settings = settings; _config = config; @@ -33,11 +36,14 @@ namespace BlueMine { apiUrlTextBox.Text = _config.RedmineUrl; apiPasswordBox.PlaceholderText = new string('●', _config.ApiKey.Length); + mainCalendar.SelectedDate = DateTime.Today; if(await TestConnection()) { - await LoadIssues(); - await GetHours(); + Task loadIssuesTask = LoadIssues(); + Task getHoursTask = GetHours(); + + await Task.WhenAll(loadIssuesTask, getHoursTask); } } @@ -51,6 +57,7 @@ namespace BlueMine var progress = new Progress<(int current, int total)>(); progress.ProgressChanged += (s, args) => { + progressRing.Visibility = Visibility.Visible; int current = args.current; int total = args.total; statusTextBlock.Text = $"{message}: {current} / {total}"; @@ -76,7 +83,7 @@ namespace BlueMine private void apiLinkButton_Click(object sender, RoutedEventArgs e) { - string url = $"{_config.RedmineUrl}/my/account"; + string url = $"{apiUrlTextBox.Text}/my/account"; var psi = new ProcessStartInfo { @@ -105,15 +112,6 @@ namespace BlueMine } } - private void DataGridSelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) - { - /*if (issuesDataGrid.SelectedItem is IssueItem item) - { - IssueNumberTextBox.Text = item.IssueNumber.ToString(); - }*/ - - } - private void SearchTextBoxTextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) { FilterIssues(); @@ -121,8 +119,10 @@ namespace BlueMine private async void RefreshButtonClick(object sender, RoutedEventArgs e) { - await LoadIssues(); - await GetHours(); + Task loadIssuesTask = LoadIssues(); + Task getHoursTask = GetHours(); + + await Task.WhenAll(loadIssuesTask, getHoursTask); } private void BrowserButtonClick(object sender, RoutedEventArgs e) @@ -143,43 +143,23 @@ namespace BlueMine private async void CloseButtonClick(object sender, RoutedEventArgs e) { - var issueNum = IssueNumberTextBox.Text; - if (int.TryParse(issueNum, out var issueId)) - { - try - { - await _manager.CloseIssueAsync(issueId); - await new Wpf.Ui.Controls.MessageBox - { - Title = "Sikeres művelet", - Content = $"A(z) {issueId} számú jegy lezárva.", - }.ShowDialogAsync(); - } - catch (Exception) - { - await new Wpf.Ui.Controls.MessageBox - { - Title = "Hiba", - Content = $"A(z) {issueId} számú jegy lezárása sikertelen.", - }.ShowDialogAsync(); - } - } + StatusList.Clear(); + var s = await _manager.GetStatusesAsync(); + foreach (var status in s) + StatusList.Add(status); - await new Wpf.Ui.Controls.MessageBox - { - Title = "Hiba", - Content = "Érvénytelen jegyszám.", - }.ShowDialogAsync(); + statusFlyout.IsOpen = true; } private async void FixButtonClick(object sender, RoutedEventArgs e) { var progress = UpdateProgress("Idők javítása:"); + progressRing.Visibility = Visibility.Visible; var i = 0; foreach (var date in mainCalendar.SelectedDates) { - var hours = 8 - await _manager.GetLoggedHoursAsync(date, date); + var hours = 8 - await _manager.GetCurrentUserTimeAsync(date, date); if (hours <= 0) continue; var message = Constants.GenericMessages[Random.Shared.Next(Constants.GenericMessages.Length)]; @@ -189,6 +169,7 @@ namespace BlueMine i++; } progressBar.Value = 0; + progressRing.Visibility = Visibility.Hidden; await GetHours(); statusTextBlock.Text = "Idők javítva"; @@ -196,6 +177,16 @@ namespace BlueMine private async void sendButton_Click(object sender, RoutedEventArgs e) { + if(mainCalendar.SelectedDates.Count == 0) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "Nap hiba", + Content = "Nincs kijelölve nap." + }.ShowDialogAsync(); + return; + } + if(int.TryParse(IssueNumberTextBox.Text, out var issueId) && double.TryParse(HoursTextBox.Text, out var hours)) { @@ -209,16 +200,17 @@ namespace BlueMine return; } var total = mainCalendar.SelectedDates.Count; - var progress = UpdateProgress("Idők beköldése:"); + var progress = UpdateProgress("Idők beküldése:"); + progressRing.Visibility = Visibility.Visible; for (int i = 0; i < total; i++) { await _manager.LogTimeAsync(issueId, hours, MessageTextBox.Text, mainCalendar.SelectedDates[i]); - progress.Report((i, total)); + progress.Report((i + 1, total)); } - progressBar.Value = 0; - statusTextBlock.Text = "Idők beküldve"; - await GetHours(); + progressBar.Value = 0; + progressRing.Visibility = Visibility.Hidden; + statusTextBlock.Text = "Idők beküldve"; } else { await new Wpf.Ui.Controls.MessageBox @@ -233,11 +225,128 @@ namespace BlueMine private void ListView_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) { var lv = sender as ListView; - if(lv != null && lv.SelectedItem is IssueItem item) + if(lv != null && lv.SelectedItem is IssueList.Issue item) { - IssueNumberTextBox.Text = item.IssueNumber.ToString(); + IssueNumberTextBox.Text = item.Id.ToString(); } } + + private async void statusSaveButton_Click(object sender, RoutedEventArgs e) + { + var issueNum = IssueNumberTextBox.Text; + if (int.TryParse(issueNum, out var issueId)) + { + try + { + var status = statusComboBox.SelectedItem as StatusList.IssueStatus; + + if (status == null) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "Érvénytelen státusz", + Content = "Státusz kiválasztása sikertelen." + }.ShowDialogAsync(); + return; + } + + await _manager.SetIssueStatusAsync(issueId, status.Id); + + var oldIssue = IssuesList.First(x=>x.Id == issueId); + var newIssue = await _manager.GetSimpleIssueAsync(issueId); + var index = IssuesList.IndexOf(oldIssue); + IssuesList.Insert(index, newIssue); + IssuesList.Remove(oldIssue); + + await new Wpf.Ui.Controls.MessageBox + { + Title = "Sikeres művelet", + Content = $"A(z) {issueId} számú jegy új státusza: {status.Name}.", + }.ShowDialogAsync(); + } + catch (Exception) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "Hiba", + Content = $"A(z) {issueId} számú jegy módosítása sikertelen.", + }.ShowDialogAsync(); + } + } + else + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "Hiba", + Content = "Érvénytelen jegyszám.", + }.ShowDialogAsync(); + } + } + + private async void searchTextBox_KeyUp(object sender, KeyEventArgs e) + { + if(e.Key == Key.Enter && searchTextBox.Text.Length > 0) + { + if (int.TryParse(searchTextBox.Text, out var issueId)) + { + try + { + statusTextBlock.Text = "Jegy keresése..."; + progressRing.Visibility = Visibility.Visible; + var issue = await _manager.GetSimpleIssueAsync(issueId); + IssuesList.Clear(); + IssuesList.Add(issue); + statusTextBlock.Text = "Jegy betöltve"; + progressRing.Visibility = Visibility.Hidden; + } catch (Exception) { + statusTextBlock.Text = "Jegy nem található"; + progressRing.Visibility = Visibility.Hidden; + } + } + } + } + + private async void openTicketButton_Click(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement button && button.DataContext is IssueList.Issue item) + { + // 2. Access the property directly from your model + var issueId = item.Id; + + try + { + statusTextBlock.Text = "Jegy betöltése..."; + progressRing.Visibility = Visibility.Visible; + var issue = await _manager.GetIssueAsync(issueId); + statusTextBlock.Text = "Jegy betöltve"; + progressRing.Visibility = Visibility.Hidden; + var issueWindow = new IssueWindow(issue, _manager, _config); + issueWindow.Show(); + } catch (Exception) + { + + statusTextBlock.Text = "Jegy betöltés sikertelen"; + progressRing.Visibility = Visibility.Hidden; + } + } + + + } + + private async void trackerButton_Click(object sender, RoutedEventArgs e) + { + if (int.TryParse(IssueNumberTextBox.Text, out var issueId)) + await OpenTimeTracker(issueId); + + } + + private async void hoursButton_Click(object sender, RoutedEventArgs e) + { + await new Wpf.Ui.Controls.MessageBox + { + Title = "Under construction" + }.ShowDialogAsync(); + } } public partial class MainWindow : FluentWindow @@ -295,22 +404,43 @@ namespace BlueMine public async Task GetHours() { - var today = await _manager.GetLoggedHoursAsync(DateTime.Today, DateTime.Today); - var yesterday = await _manager.GetLoggedHoursAsync(DateTime.Today.AddDays(-1), DateTime.Today.AddDays(-1)); + todayProgressRing.Visibility = + yesterdayProgressRing.Visibility = + monthProgressRing.Visibility = Visibility.Visible; + var today = await _manager.GetCurrentUserTimeTodayAsync(); + var yesterday = await _manager.GetCurrentUserTimeYesterdayAsync(); + var thisMonth = await _manager.GetCurrentUserTimeThisMonthAsync(); - var m = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); - var thisMonth = await _manager.GetLoggedHoursAsync(m, m.AddMonths(1).AddDays(-1)); + int workingDays = 0; + DateTime currentDate = DateTime.Today; + + for (int day = 1; day <= currentDate.Day; day++) + { + var dateToCheck = new DateTime(currentDate.Year, currentDate.Month, day); + if (dateToCheck.DayOfWeek != DayOfWeek.Saturday && + dateToCheck.DayOfWeek != DayOfWeek.Sunday) + { + workingDays++; + } + } + + var avgHours = Math.Round(thisMonth/workingDays, 2); todayTimeLabel.Text = today.ToString(); yesterdayTimeLabel.Text = yesterday.ToString(); monthTimeLabel.Text = thisMonth.ToString(); + averageTimeLabel.Text = avgHours.ToString(); + + todayProgressRing.Visibility = + yesterdayProgressRing.Visibility = + monthProgressRing.Visibility = Visibility.Hidden; } public void FilterIssues() { var list = string.IsNullOrWhiteSpace(searchTextBox.Text) ? _issues - : _issues.Where(issue => issue.IssueName.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase) - || issue.IssueNumber.ToString().Contains(searchTextBox.Text) + : _issues.Where(issue => issue.Subject.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase) + || issue.Id.ToString().Contains(searchTextBox.Text) || issue.ProjectName.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase)); IssuesList.Clear(); foreach (var item in list) @@ -321,9 +451,14 @@ namespace BlueMine public async Task LoadIssues() { _issues.Clear(); - _issues.AddRange(Constants.StaticTickets); - _issues.AddRange(await _manager.GetCurrentIssuesAsync(UpdateProgress("Jegyek letöltése:"))); + statusTextBlock.Text = "Jegyek letöltése..."; + progressRing.Visibility = Visibility.Visible; + foreach (var issueId in Constants.StaticTickets) + _issues.Add(await _manager.GetSimpleIssueAsync(issueId)); + + _issues.AddRange(await _manager.GetCurrentUserIssuesAsync(progress: UpdateProgress("Jegyek letöltése:"))); progressBar.Value = 0; + progressRing.Visibility = Visibility.Hidden; FilterIssues(); statusTextBlock.Text = "Jegyek letöltve"; } @@ -359,19 +494,49 @@ namespace BlueMine } public async Task TestConnection() { - if (!await _manager.IsRedmineAvailable()) + statusTextBlock.Text = $"Kapcsolódás Redminehoz..."; + + int maxRetries = 3; + int timeoutSeconds = 1; // Force kill after 5s + + for (int i = 0; i < maxRetries; i++) { - DisableUi(); - apiButton.Appearance = Wpf.Ui.Controls.ControlAppearance.Primary; - return false; - } - else - { - EnableUi(); - apiButton.Appearance = Wpf.Ui.Controls.ControlAppearance.Secondary; - statusTextBlock.Text = "Kapcsolódva"; - return true; + try + { + // Creates a token that cancels automatically + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + + // Pass the token. If it hangs, this throws OperationCanceledException + if (await _manager.IsRedmineAvailable(cts.Token)) + { + EnableUi(); + apiButton.Appearance = ControlAppearance.Secondary; + statusTextBlock.Text = "Kapcsolódva"; + return true; + } + } + catch (Exception) + { + // Ignore timeout/error and try again unless it's the last attempt + if (i == maxRetries - 1) break; + } + + statusTextBlock.Text = $"Kapcsolódási hiba. Újrapróbálkozás: {i + 1}/{maxRetries}"; + + // Wait 1 second before retrying + await Task.Delay(1000); } + + // All attempts failed + DisableUi(); + apiButton.Appearance = ControlAppearance.Primary; + return false; + } + public async Task OpenTimeTracker(int issueId) + { + var i = await _manager.GetSimpleIssueAsync(issueId); + var timer = new TimeTrackerWindow(_config, _manager, i); + timer.Show(); } } } \ No newline at end of file diff --git a/Blueberry/Redmine/AsyncLock.cs b/Blueberry/Redmine/AsyncLock.cs deleted file mode 100644 index 58d58af..0000000 --- a/Blueberry/Redmine/AsyncLock.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace BlueMine.Redmine -{ - public class AsyncLock - { - private readonly SemaphoreSlim _semaphore = new(1, 1); - - public async Task LockAsync() - { - await _semaphore.WaitAsync(); - return new Disposable(() => _semaphore.Release()); - } - - private class Disposable : IDisposable - { - private readonly Action _action; - - public Disposable(Action action) - { - _action = action; - } - - public void Dispose() - { - _action(); - } - } - } -} - diff --git a/Blueberry/Redmine/IRedmineCache.cs b/Blueberry/Redmine/IRedmineCache.cs deleted file mode 100644 index 91cd3b5..0000000 --- a/Blueberry/Redmine/IRedmineCache.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace BlueMine.Redmine -{ - public interface IRedmineCache - { - void RefreshCache(List newItems); - void InvalidateCache(); - bool IsCacheValid(); - List GetItems(); - } -} \ No newline at end of file diff --git a/Blueberry/Redmine/IRedmineConnect.cs b/Blueberry/Redmine/IRedmineConnect.cs deleted file mode 100644 index 7908c28..0000000 --- a/Blueberry/Redmine/IRedmineConnect.cs +++ /dev/null @@ -1,18 +0,0 @@ -using static BlueMine.Redmine.RedmineDto; - -namespace BlueMine.Redmine -{ - public interface IRedmineConnect - { - Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null); - Task CloseIssueAsync(int issueId); - Task CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null); - Task> GetProjectsAsync(int limit = 25, IProgress<(int, int)>? progress = null); - Task> GetTrackersAsync(string projectId, CancellationToken? token = null); - Task VerifyApiKey(); - Task> GetMyIssuesAsync(int limit = 25, IProgress<(int, int)>? progress = null); - Task GetIssueTotalTimeAsync(int issueId); - Task GetTodaysHoursAsync(DateTime startDate, DateTime endDate); - Task> GetSpentTimeForIssuesAsync(List simpleIssues, IProgress<(int, int)>? progress = null); - } -} \ No newline at end of file diff --git a/Blueberry/Redmine/IRedmineManager.cs b/Blueberry/Redmine/IRedmineManager.cs deleted file mode 100644 index 61b345f..0000000 --- a/Blueberry/Redmine/IRedmineManager.cs +++ /dev/null @@ -1,16 +0,0 @@ -using static BlueMine.Redmine.RedmineDto; - -namespace BlueMine.Redmine -{ - public interface IRedmineManager - { - Task IsRedmineAvailable(); - Task LogTimeAsync(int issueId, double hours, string comments, DateTime date, int? activityId = null); - Task CloseIssueAsync(int issueId); - Task CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null); - Task> GetProjectsAsync(int limit = 100, IProgress<(int, int)>? progress = null); - Task> GetTrackersAsync(string projectId, CancellationToken? token = null); - Task> GetCurrentIssuesAsync(IProgress<(int, int)>? progress = null); - Task GetLoggedHoursAsync(DateTime? startDate = null, DateTime? endDate = null); - } -} \ No newline at end of file diff --git a/Blueberry/Redmine/RedmineAuthHandler.cs b/Blueberry/Redmine/RedmineAuthHandler.cs deleted file mode 100644 index 6cd0872..0000000 --- a/Blueberry/Redmine/RedmineAuthHandler.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.Extensions.Logging; -using System.Net.Http; - -namespace BlueMine.Redmine -{ - public class RedmineAuthHandler : DelegatingHandler - { - private readonly RedmineConfig _config; - private readonly ILogger _logger; - - public RedmineAuthHandler(RedmineConfig config, ILogger logger) - { - _logger = logger; - _config = config; - } - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - _logger.LogDebug("Checking config for valid options"); - if (!string.IsNullOrWhiteSpace(_config.ApiKey)) - { - _logger.LogDebug("Refreshing API key"); - request.Headers.Remove("X-Redmine-API-Key"); - request.Headers.Add("X-Redmine-API-Key", _config.ApiKey); - } - - if (!string.IsNullOrWhiteSpace(_config.RedmineUrl) - && request.RequestUri != null) - { - _logger.LogDebug("Refreshing base URI"); - string baseUrlStr = _config.RedmineUrl.EndsWith("/") - ? _config.RedmineUrl - : _config.RedmineUrl + "/"; - - var baseUri = new Uri(baseUrlStr); - - string pathAndQuery = request.RequestUri.PathAndQuery; - - if (pathAndQuery.StartsWith("/")) - { - pathAndQuery = pathAndQuery.Substring(1); - } - - request.RequestUri = new Uri(baseUri, pathAndQuery); - } - - return await base.SendAsync(request, cancellationToken); - } - } -} diff --git a/Blueberry/Redmine/RedmineCache.cs b/Blueberry/Redmine/RedmineCache.cs deleted file mode 100644 index 900636f..0000000 --- a/Blueberry/Redmine/RedmineCache.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace BlueMine.Redmine -{ - class RedmineCache : IRedmineCache - { - private List Items { get; set; } = []; - - public DateTime LastUpdated { get; set; } = DateTime.MinValue; - private readonly ILogger _logger; - private readonly TimeSpan _cacheDuration; - private readonly object _lock = new(); - public RedmineCache(TimeSpan cacheDuration, ILogger> logger) - { - _logger = logger; - _cacheDuration = cacheDuration; - } - public RedmineCache(int cacheDurationSec, ILogger> logger) - { - _logger = logger; - _cacheDuration = new TimeSpan(0, 0, cacheDurationSec); - } - - public void RefreshCache(List newItems) - { - _logger.LogDebug($"Refreshing cache with {newItems.Count} items"); - lock (_lock) - { - Items = newItems; - LastUpdated = DateTime.UtcNow; - _logger.LogDebug("Cache refreshed"); - } - } - - public void InvalidateCache() - { - _logger.LogDebug("Invalidating cache"); - lock (_lock) - { - LastUpdated = DateTime.MinValue; - _logger.LogDebug("Cache invalidated"); - } - } - - public bool IsCacheValid() - { - lock (_lock) - { - bool valid = DateTime.UtcNow - LastUpdated <= _cacheDuration; - _logger.LogDebug($"Cache valid: {valid}"); - return valid; - } - } - - public List GetItems() - { - lock (_lock) - { - _logger.LogDebug($"Returning {Items.Count} cached items"); - return Items; - } - } - } -} diff --git a/Blueberry/Redmine/RedmineConnect.cs b/Blueberry/Redmine/RedmineConnect.cs deleted file mode 100644 index 4fdc809..0000000 --- a/Blueberry/Redmine/RedmineConnect.cs +++ /dev/null @@ -1,379 +0,0 @@ -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Collections.Concurrent; -using static BlueMine.Redmine.RedmineDto; -using Microsoft.Extensions.Logging; - -namespace BlueMine.Redmine -{ - public class RedmineConnect : IRedmineConnect - { - readonly HttpClient _httpClient; - private readonly JsonSerializerOptions _jsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - private readonly SemaphoreSlim _concurrencySemaphore; - private readonly RedmineConfig _config; - private readonly ILogger _logger; - private int _userId = -1; - - public RedmineConnect(HttpClient client, ILogger logger, RedmineConfig config) - { - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(config); - - _httpClient = client; - _logger = logger; - _config = config; - _concurrencySemaphore = new(config.ConcurrencyLimit, config.ConcurrencyLimit); - } - - private async Task SendRequestAsync(HttpMethod method, string endpoint, object? payload = null, CancellationToken? token = null) - { - string url = $"{_config.RedmineUrl}/{endpoint}"; - int maxRetries = _config.MaxRetries; - int retryDelayMilliseconds = 2000; - CancellationToken cancellationToken = token ?? CancellationToken.None; - - for (int attempt = 0; attempt <= maxRetries; attempt++) - { - using var request = new HttpRequestMessage(method, url); - request.Headers.Add("X-Redmine-API-Key", _config.ApiKey); - - if (payload != null) - { - string json = JsonSerializer.Serialize(payload, _jsonOptions); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - } - if(cancellationToken.IsCancellationRequested) - { - _logger.LogInformation("Request cancelled by token"); - cancellationToken.ThrowIfCancellationRequested(); - } - - var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - - if (response.IsSuccessStatusCode) - { - if (response.Content.Headers.ContentLength == 0) - return default; - - var responseStream = await response.Content.ReadAsStreamAsync(); - return await JsonSerializer.DeserializeAsync(responseStream, _jsonOptions); - } - - bool isServerError = (int)response.StatusCode >= 500 && (int)response.StatusCode < 600; - - if (isServerError && attempt < maxRetries) - { - _logger.LogWarning("Received {StatusCode} from Redmine. Retrying in {Delay}ms (Attempt {Attempt} of {MaxRetries})", response.StatusCode, retryDelayMilliseconds, attempt + 1, maxRetries); - - response.Dispose(); - - await Task.Delay(retryDelayMilliseconds).ConfigureAwait(false); - continue; - } - - string errorBody = await response.Content.ReadAsStringAsync(); - response.Dispose(); - - _logger.LogError("Error ({StatusCode}): {ErrorBody}", response.StatusCode, errorBody); - throw new RedmineApiException($"Redmine API Error {response.StatusCode}: {errorBody}", (int)response.StatusCode); - } - - throw new RedmineApiException("Redmine API Unreachable"); - } - - public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null) - { - string url = $"time_entries.json"; - string dateString = (date ?? DateTime.Now).ToString("yyyy-MM-dd"); - - var payload = new TimeEntryRequest - { - TimeEntry = new TimeEntry - { - IssueId = issueId, - Hours = hours, - Comments = comments, - SpentOn = dateString, - ActivityId = activityId - } - }; - - var response = await SendRequestAsync(HttpMethod.Post, url, payload); - } - - public async Task CloseIssueAsync(int issueId) - { - _logger.LogDebug("Closing issue {IssueId}", issueId); - _logger.LogInformation("Closing issue {IssueId}", issueId); - string url = $"issues/{issueId}.json"; - - var payload = new - { - issue = new - { - status_id = RedmineConstants.ClosedStatusId - } - }; - - var response = await SendRequestAsync(HttpMethod.Put, url, payload); - _logger.LogInformation("Closed {IssueId}", issueId); - _logger.LogDebug("Issue {IssueId} closed successfully", issueId); - } - - public async Task CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null) - { - _logger.LogDebug("Creating issue in project {ProjectId} with subject '{Subject}'", projectId, subject); - if (_userId == -1) - { - await VerifyApiKey(); - } - - string url = $"issues.json"; - - var issueDict = new Dictionary - { - ["project_id"] = projectId, - ["tracker_id"] = trackerId, - ["subject"] = subject, - ["assigned_to_id"] = _userId - }; - - if (!string.IsNullOrEmpty(description)) - issueDict["description"] = description; - - if (estimatedHours.HasValue) - issueDict["estimated_hours"] = estimatedHours.Value; - - if (priorityId.HasValue) - issueDict["priority_id"] = priorityId.Value; - - if (parentIssueId.HasValue) - issueDict["parent_issue_id"] = parentIssueId.Value; - - if (estimatedHours.HasValue) - { - issueDict["custom_fields"] = new[] - { - new - { - id = RedmineConstants.EstimatedHoursCustomFieldId, - value = estimatedHours.Value.ToString() - } - }; - } - - var issue = issueDict; - - var payload = new - { - issue - }; - - var response = await SendRequestAsync(HttpMethod.Post, url, payload); - var issueId = response?.Issue?.Id ?? throw new Exception("Failed to parse created issue response"); - _logger.LogInformation("Issue {IssueId} created with subject '{Subject}'", issueId, subject); - _logger.LogDebug("Issue {IssueId} created successfully", issueId); - return issueId; - } - - public async Task> GetProjectsAsync(int limit = 25, IProgress<(int, int)>? progress = null) - { - int offset = 0; - int totalCount = 0; - var projects = new ConcurrentBag(); - - while (true) - { - string url = $"projects.json?limit={limit}&offset={offset}"; - - var response = await SendRequestAsync(HttpMethod.Get, url); - - if (response?.Projects != null) - { - totalCount = response.TotalCount; - foreach (var p in response.Projects) - { - projects.Add(new SimpleProject - { - Id = p.Id, - Name = p.Name, - Identifier = p.Identifier - }); - } - progress?.Report((offset + response.Projects.Count, totalCount)); - } - - if (response == null || offset + limit >= totalCount) - break; - - offset += limit; - } - - _logger.LogInformation("Fetched projects from API"); - - return projects; - } - - public async Task> GetTrackersAsync(string projectId, CancellationToken? token = null) - { - string url = $"projects/{projectId}.json?include=trackers"; - - var response = await SendRequestAsync(HttpMethod.Get, url, token: token); - - var trackers = response?.Project?.Trackers.Select(t => new SimpleTracker - { - Id = t.Id, - Name = t.Name - }).ToList() ?? new List(); - - _logger.LogInformation("Fetched {Count} trackers from API", trackers.Count); - - return trackers; - } - - public async Task VerifyApiKey() - { - _logger.LogDebug("Verifying API key"); - _logger.LogInformation("Verifying API Key and fetching user ID"); - const int maxAttempts = 3; - for (int attempts = 0; attempts < maxAttempts; attempts++) - { - string url = $"issues.json?assigned_to_id=me&status_id=open&limit=1"; - - var response = await SendRequestAsync(HttpMethod.Get, url); - - var userid = response?.Issues.FirstOrDefault()?.AssignedTo?.Id; - - if (userid != null && userid != -1) - { - _userId = userid.Value; - _logger.LogInformation("API Key verified. User ID: {UserId}", _userId); - _logger.LogDebug("User ID set to {UserId}", _userId); - return; - } - - _logger.LogDebug("User ID not found, retrying (attempt {Attempt}/{Max})", attempts + 1, maxAttempts); - if (attempts < maxAttempts - 1) - { - await Task.Delay(1000); // short delay - } - } - - throw new InvalidOperationException("Failed to verify API key after maximum attempts"); - } - - public async Task> GetMyIssuesAsync(int limit = 25, IProgress<(int, int)>? progress = null) - { - var offset = 0; - int totalCount = 0; - - var issues = new ConcurrentBag(); - while(true) - { - string url = $"issues.json?assigned_to_id=me&status_id=open&limit={limit}&offset={offset}"; - - var response = await SendRequestAsync(HttpMethod.Get, url); - if (response?.Issues != null) - { - totalCount = response.TotalCount; - foreach (var i in response.Issues) - { - issues.Add(new SimpleIssue - { - Id = i.Id, - ProjectName = i.Project?.Name ?? "Unknown", - Subject = i.Subject, - Description = i.Description, - Created = i.Created, - Updated = i.Updated - }); - } - progress?.Report((offset + response.Issues.Count, totalCount)); - } - - if (response == null || offset + limit >= totalCount) - break; - - offset += limit; - } - - _logger.LogInformation("Fetched issues from API"); - - return issues; - } - - public async Task GetIssueTotalTimeAsync(int issueId) - { - string url = $"time_entries.json?issue_id={issueId}&limit=100"; - - var response = await SendRequestAsync(HttpMethod.Get, url); - - if (response?.TimeEntries != null) - { - return response.TimeEntries.Sum(t => t.Hours); - } - return 0.0; - } - - public async Task GetTodaysHoursAsync(DateTime startDate, DateTime endDate) - { - _logger.LogDebug("Getting hours from {StartDate} to {EndDate}", startDate.ToShortDateString(), endDate.ToShortDateString()); - string start = startDate.ToString("yyyy-MM-dd"); - string end = endDate.ToString("yyyy-MM-dd"); - string url = $"time_entries.json?from={start}&to={end}&user_id={_userId}"; - - var response = await SendRequestAsync(HttpMethod.Get, url); - - double total = response?.TimeEntries?.Sum(t => t.Hours) ?? 0; - _logger.LogInformation("Fetched hours: {Total}", total); - _logger.LogDebug("Total hours: {Total}", total); - return total; - } - - public async Task> GetSpentTimeForIssuesAsync(List simpleIssues, IProgress<(int, int)>? progress = null) - { - _logger.LogDebug("Getting current issues with spent time"); - _logger.LogDebug("Retrieved {Count} simple issues", simpleIssues.Count); - var issueItems = new ConcurrentBag(); - var tasks = new List(); - for (int i = 0; i < simpleIssues.Count; i++) - { - SimpleIssue si = simpleIssues[i]; - var task = Task.Run(async () => - { - await _concurrencySemaphore.WaitAsync(); - try - { - var spent = await GetIssueTotalTimeAsync(si.Id); - issueItems.Add(new IssueItem - { - ProjectName = si.ProjectName, - IssueName = si.Subject, - IssueNumber = si.Id, - IssueDescription = si.Description, - Updated = si.Updated, - Created = si.Created, - SpentTime = spent - }); - _logger.LogDebug("Retrieved total time for issue {IssueId}: {Spent} hours", si.Id, spent); - } - finally - { - _concurrencySemaphore.Release(); - progress?.Report((issueItems.Count, simpleIssues.Count)); - } - }); - tasks.Add(task); - } - await Task.WhenAll(tasks); - - _logger.LogInformation("Processed {Count} issues with spent time", issueItems.Count); - _logger.LogDebug("Finished processing issues"); - return [.. issueItems]; - } - } -} \ No newline at end of file diff --git a/Blueberry/Redmine/RedmineDto.cs b/Blueberry/Redmine/RedmineDto.cs deleted file mode 100644 index 4843177..0000000 --- a/Blueberry/Redmine/RedmineDto.cs +++ /dev/null @@ -1,185 +0,0 @@ -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. -using System.Text.Json.Serialization; - -namespace BlueMine.Redmine -{ - public static class RedmineConstants - { - public const int ClosedStatusId = 30; - public const int EstimatedHoursCustomFieldId = 65; - } - - public class RedmineDto - { - public class SimpleIssue - { - public int Id { get; set; } - public string ProjectName { get; set; } - public string Subject { get; set; } - public string Description { get; set; } - public DateTime Created { get; set; } - public DateTime Updated { get; set; } - } - - public class SimpleProject - { - public int Id { get; set; } - public string Name { get; set; } - public string Identifier { get; set; } - } - - public class SimpleTracker - { - public int Id { get; set; } - public string Name { get; set; } - } - - public class IssueItem - { - public string ProjectName { get; set; } - public string IssueName { get; set; } - public string IssueDescription { get; set; } - public int IssueNumber { get; set; } - public double SpentTime { get; set; } - public DateTime Created { get; set; } - public DateTime Updated { get; set; } - public string LastUpdate { get - { - var span = DateTime.Now - Updated; - - if (span.TotalMinutes < 1) return "pp most"; - if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes} perce"; - if (span.TotalHours < 24) return $"{(int)span.TotalHours} rja"; - if (span.TotalDays < 7) return $"{(int)span.TotalDays} napja"; - if (span.TotalDays < 30) return $"{(int)(span.TotalDays / 7)} hete"; - if (span.TotalDays < 365) return $"{(int)(span.TotalDays / 30)} hnapja"; - - return $"{(int)(span.TotalDays / 365)} ve"; - } - } - } - - public class TimeEntryRequest - { - [JsonPropertyName("time_entry")] - public TimeEntry TimeEntry { get; set; } - } - - public class TimeEntry - { - [JsonPropertyName("issue_id")] - public int IssueId { get; set; } - [JsonPropertyName("hours")] - public double Hours { get; set; } - [JsonPropertyName("comments")] - public string Comments { get; set; } - [JsonPropertyName("spent_on")] - public string SpentOn { get; set; } - [JsonPropertyName("activity_id")] - public int? ActivityId { get; set; } - } - - public class IssueListResponse - { - [JsonPropertyName("issues")] - public List Issues { get; set; } - [JsonPropertyName("total_count")] - public int TotalCount { get; set; } - [JsonPropertyName("limit")] - public int Limit { get; set; } - [JsonPropertyName("offset")] - public int Offset { get; set; } - } - - public class CreateIssueResponse - { - [JsonPropertyName("issue")] - public IssueDto Issue { get; set; } - } - - public class IssueDto - { - [JsonPropertyName("id")] - public int Id { get; set; } - [JsonPropertyName("project")] - public IdNameDto Project { get; set; } - [JsonPropertyName("subject")] - public string Subject { get; set; } - [JsonPropertyName("assigned_to")] - public AssignedToDto AssignedTo { get; set; } - [JsonPropertyName("description")] - public string Description { get; set; } - [JsonPropertyName("created_on")] - public DateTime Created { get; set; } - [JsonPropertyName("updated_on")] - public DateTime Updated { get; set; } - } - - public class IdNameDto - { - [JsonPropertyName("name")] - public string Name { get; set; } - } - - public class AssignedToDto - { - [JsonPropertyName("id")] - public int Id { get; set; } - } - - public class TimeEntryListResponse - { - [JsonPropertyName("time_entries")] - public List TimeEntries { get; set; } - } - - public class TimeEntryDto - { - [JsonPropertyName("hours")] - public double Hours { get; set; } - } - - public class ProjectListResponse - { - [JsonPropertyName("projects")] - public List Projects { get; set; } - [JsonPropertyName("total_count")] - public int TotalCount { get; set; } - [JsonPropertyName("limit")] - public int Limit { get; set; } - [JsonPropertyName("offset")] - public int Offset { get; set; } - } - - public class ProjectDto - { - [JsonPropertyName("id")] - public int Id { get; set; } - [JsonPropertyName("name")] - public string Name { get; set; } - [JsonPropertyName("identifier")] - public string Identifier { get; set; } - } - - public class ProjectWithTrackersResponse - { - [JsonPropertyName("project")] - public ProjectWithTrackersDto Project { get; set; } - } - - public class ProjectWithTrackersDto - { - [JsonPropertyName("trackers")] - public List Trackers { get; set; } - } - - public class TrackerDto - { - [JsonPropertyName("id")] - public int Id { get; set; } - [JsonPropertyName("name")] - public string Name { get; set; } - } - } -} -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry/Redmine/RedmineManager.cs b/Blueberry/Redmine/RedmineManager.cs deleted file mode 100644 index 92ac610..0000000 --- a/Blueberry/Redmine/RedmineManager.cs +++ /dev/null @@ -1,206 +0,0 @@ -using Microsoft.Extensions.Logging; -using System.Net.Http; -using static BlueMine.Redmine.RedmineDto; - -namespace BlueMine.Redmine -{ - public class RedmineManager : IRedmineManager - { - private readonly RedmineConnect _redmineConnect; - private readonly AsyncLock _lock = new(); - private readonly RedmineCache _projectCache; - private readonly RedmineCache _issueCache; - private readonly ILogger _logger; - - public RedmineManager(HttpClient httpClient, ILoggerFactory loggerFactory, RedmineConfig config) - { - ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(loggerFactory); - ArgumentNullException.ThrowIfNull(config); - - _logger = loggerFactory.CreateLogger(); - _logger.LogDebug("Initializing RedmineManager with URL: {Url}", config.RedmineUrl); - _redmineConnect = new RedmineConnect(httpClient, loggerFactory.CreateLogger(), config); - _projectCache = new(config.ProjectCacheDuration, loggerFactory.CreateLogger>()); - _issueCache = new(config.IssueCacheDuration, loggerFactory.CreateLogger>()); - _logger.LogDebug("RedmineManager initialized"); - } - - /// - /// Checks if the Redmine instance is available by verifying the API key. - /// - /// True if available, false otherwise. - public async Task IsRedmineAvailable() - { - _logger.LogDebug("Checking if Redmine is available"); - try - { - using (await _lock.LockAsync()) - { - await _redmineConnect.VerifyApiKey(); - } - _logger.LogDebug("Redmine is available"); - return true; - } - catch (Exception ex) - { - _logger.LogDebug("Redmine not available: {Message}", ex.Message); - return false; - } - } - /// - /// Logs time for a specific issue. - /// - /// The issue ID. - /// Hours to log. - /// Comments for the time entry. - /// Date of the time entry. - /// Optional activity ID. - public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime date, int? activityId = null) - { - _logger.LogDebug("Logging {Hours} hours for issue {IssueId} on {Date}", hours, issueId, date.ToShortDateString()); - using (await _lock.LockAsync()) - { - await _redmineConnect.LogTimeAsync(issueId, hours, comments, date, activityId); - } - _logger.LogDebug("Time logged successfully"); - } - /// - /// Closes the specified issue. - /// - /// The issue ID to close. - public async Task CloseIssueAsync(int issueId) - { - _logger.LogDebug("Closing issue {IssueId}", issueId); - using (await _lock.LockAsync()) - { - await _redmineConnect.CloseIssueAsync(issueId); - } - _logger.LogDebug("Issue {IssueId} closed", issueId); - } - /// - /// Creates a new issue in the specified project. - /// - /// The project ID. - /// The tracker ID. - /// Issue subject. - /// Optional description. - /// Optional estimated hours. - /// Optional priority ID. - /// Optional parent issue ID. - /// The created issue ID. - public async Task CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, - double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null) - { - _logger.LogDebug("Creating issue in project {ProjectId} with subject '{Subject}'", projectId, subject); - using (await _lock.LockAsync()) - { - var issueId = await _redmineConnect.CreateIssueAsync(projectId, trackerId, subject, description, - estimatedHours, priorityId, parentIssueId); - _logger.LogDebug("Issue created with ID {IssueId}", issueId); - return issueId; - } - } - /// - /// Retrieves the list of projects, using cache if valid. - /// - /// Maximum number of projects to fetch per request. - /// Optional progress reporter. - /// List of simple projects. - public async Task> GetProjectsAsync(int limit = 100, IProgress<(int, int)>? progress = null) - { - _logger.LogDebug("Getting projects"); - using (await _lock.LockAsync()) - { - List projects = []; - - if(!_projectCache.IsCacheValid()) - { - _logger.LogDebug("Cache invalid, refreshing"); - _projectCache.RefreshCache([..(await _redmineConnect.GetProjectsAsync(limit, progress))]); - } - else - { - _logger.LogDebug("Using cached projects"); - } - - projects = _projectCache.GetItems(); - _logger.LogDebug("Retrieved {Count} projects", projects.Count); - return projects; - } - } - /// - /// Retrieves trackers for the specified project. - /// - /// The project ID. - /// Optional cancellation token. - /// List of simple trackers. - public async Task> GetTrackersAsync(string projectId, CancellationToken? token = null) - { - _logger.LogDebug("Getting trackers for project {ProjectId}", projectId); - try - { - using (await _lock.LockAsync()) - { - var trackers = await _redmineConnect.GetTrackersAsync(projectId, token); - _logger.LogDebug("Retrieved {Count} trackers", trackers.Count); - return trackers; - } - } - catch (OperationCanceledException) - { - _logger.LogDebug("GetTrackersAsync cancelled"); - throw; - } - } - /// - /// Retrieves current issues with spent time. - /// - /// Optional progress reporter. - /// List of issue items. - public async Task> GetCurrentIssuesAsync(IProgress<(int, int)>? progress = null) - { - _logger.LogDebug("Getting current issues"); - using (await _lock.LockAsync()) - { - List simpleIssues; - if (!_issueCache.IsCacheValid()) - { - _logger.LogDebug("Issue cache invalid, refreshing"); - simpleIssues = [.. (await _redmineConnect.GetMyIssuesAsync())]; - _issueCache.RefreshCache(simpleIssues); - } - else - { - _logger.LogDebug("Using cached issues"); - simpleIssues = _issueCache.GetItems(); - } - var issues = await _redmineConnect.GetSpentTimeForIssuesAsync(simpleIssues, progress); - _logger.LogDebug("Retrieved {Count} issues", issues.Count); - return issues; - } - } - /// - /// Retrieves logged hours for the specified date range. - /// - /// Start date. - /// End date. - /// Total logged hours. - public async Task GetLoggedHoursAsync(DateTime? startDate = null, DateTime? endDate = null) - { - var start = DateTime.Today; - var end = DateTime.Today; - if (startDate.HasValue) - start = startDate.Value; - if(endDate.HasValue) - end = endDate.Value; - _logger.LogDebug("Getting logged hours from {Start} to {End}", start.ToShortDateString(), end.ToShortDateString()); - using (await _lock.LockAsync()) - { - var hours = await _redmineConnect.GetTodaysHoursAsync(start, end); - _logger.LogDebug("Retrieved {Hours} hours", hours); - return hours; - } - } - } -} diff --git a/Blueberry/StringToColorConverter.cs b/Blueberry/StringToColorConverter.cs new file mode 100644 index 0000000..dc5b211 --- /dev/null +++ b/Blueberry/StringToColorConverter.cs @@ -0,0 +1,80 @@ +using System.Windows.Media; + +namespace Blueberry +{ + public static class StringToColorConverter + { + public static SolidColorBrush GetColorFromName(string name) + { + // 1. Get a deterministic hash from the string + int hash = GetStableHashCode(name); + + // 2. Generate HSL values based on the hash + + // Hue: 0 to 360 (The entire color wheel) + double hue = Math.Abs(hash % 360); + + // Saturation: 25 to 100 + // We use a bit-shift or different modulo to ensure S and L aren't identical to Hue + double saturation = 25 + (Math.Abs((hash / 2) % 76)); // 0 to 75 + 25 = 25-100 + + // Lightness: 25 to 75 + double lightness = 25 + (Math.Abs((hash / 3) % 51)); // 0 to 50 + 25 = 25-75 + + // 3. Convert HSL to RGB + Color color = ColorFromHSL(hue, saturation / 100.0, lightness / 100.0); + + return new SolidColorBrush(color); + } + + // Helper: Standard string.GetHashCode() can vary by platform/version. + // This simple loop ensures "Alice" is always the same color on every machine. + private static int GetStableHashCode(string str) + { + unchecked + { + int hash = 23; + foreach (char c in str) + { + hash = hash * 31 + c; + } + return hash; + } + } + + // Helper: HSL to RGB Math + private static Color ColorFromHSL(double h, double s, double l) + { + double r = 0, g = 0, b = 0; + + if (s == 0) + { + r = g = b = l; // Achromatic (Grey) + } + else + { + double q = l < 0.5 ? l * (1 + s) : l + s - l * s; + double p = 2 * l - q; + + r = HueToRGB(p, q, h / 360.0 + 1.0 / 3.0); + g = HueToRGB(p, q, h / 360.0); + b = HueToRGB(p, q, h / 360.0 - 1.0 / 3.0); + } + + return Color.FromRgb( + (byte)(r * 255), + (byte)(g * 255), + (byte)(b * 255)); + } + + private static double HueToRGB(double p, double q, double t) + { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t; + if (t < 1.0 / 2.0) return q; + if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0; + return p; + } + } +} diff --git a/Blueberry/TimeTrackerWindow.xaml b/Blueberry/TimeTrackerWindow.xaml new file mode 100644 index 0000000..998eb0f --- /dev/null +++ b/Blueberry/TimeTrackerWindow.xaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Blueberry/TimeTrackerWindow.xaml.cs b/Blueberry/TimeTrackerWindow.xaml.cs new file mode 100644 index 0000000..63e0e94 --- /dev/null +++ b/Blueberry/TimeTrackerWindow.xaml.cs @@ -0,0 +1,108 @@ +using Blueberry.Redmine; +using Blueberry.Redmine.Dto; +using System.Timers; +using System.Windows.Input; +using Wpf.Ui.Controls; + +namespace Blueberry +{ + /// + /// Interaction logic for TimeTrackerWindow.xaml + /// + public partial class TimeTrackerWindow : FluentWindow + { + private readonly RedmineConfig _config; + private readonly RedmineManager _manager; + private readonly IssueList.Issue _issue; + public string CurrentIssue => _issue.Subject; + private readonly System.Timers.Timer _timer; + private TimeSpan _elapsedTime; + + public TimeTrackerWindow(RedmineConfig config, RedmineManager manager, IssueList.Issue issue) + { + DataContext = this; + _config = config; + _manager = manager; + _issue = issue; + _timer = new System.Timers.Timer(new TimeSpan(0, 0, 1)); + _timer.Elapsed += TimerElapsed; + _elapsedTime = new TimeSpan(0); + InitializeComponent(); + } + + private void FluentWindow_Loaded(object sender, System.Windows.RoutedEventArgs e) + { + issueTextBlock.Text = "#" + _issue.Id; + } + + private void TimerElapsed(object? sender, ElapsedEventArgs e) + { + _elapsedTime = _elapsedTime.Add(new(0, 0, 1)); + Dispatcher.Invoke(new Action(() => + { + timeTextBlock.Text = _elapsedTime.ToString(@"hh\:mm\:ss"); + })); + } + + private void playPauseButton_Click(object sender, System.Windows.RoutedEventArgs e) + { + if(_timer.Enabled) + { + _timer.Stop(); + playPauseIcon.Symbol = SymbolRegular.Play24; + } + else + { + _timer.Start(); + playPauseIcon.Symbol = SymbolRegular.Pause24; + } + } + + private void doneButton_Click(object sender, System.Windows.RoutedEventArgs e) + { + _timer.Stop(); + double hour = GetCurrentHours(); + + resultHourTextBlock.Text = $"{hour}"; + resultFlyout.IsOpen = true; + } + + private double GetCurrentHours() + { + var hour = (_elapsedTime.TotalMinutes + 10) / 15; + hour = Math.Ceiling(hour / 15.0) * 15.0; + hour /= 60.0; + return hour; + } + + private void FluentWindow_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + DragMove(); + } + + private void cancelButton_Click(object sender, System.Windows.RoutedEventArgs e) + { + Close(); + } + + private async void resultSaveButton_Click(object sender, System.Windows.RoutedEventArgs e) + { + var hours = GetCurrentHours(); + var result = await new MessageBox + { + Title = "Ellenőrzés", + Content = $"Jegy: {_issue.Id}\nÓra: {hours}\nÜzenet: {resultTextBox.Text}\n\nBiztos, hogy beküldöd?", + PrimaryButtonText = "Igen", + SecondaryButtonText = "Nem", + IsCloseButtonEnabled = false, + }.ShowDialogAsync(); + + if (result == MessageBoxResult.Primary) + { + await _manager.LogTimeAsync(_issue.Id, hours, resultTextBox.Text, DateTime.Today); + await new MessageBox { Title = "Sikeres beküldés", Content = $"A {_issue.Id} jegyre {hours} óra lett felírva." }.ShowDialogAsync(); + Close(); + } + } + } +} diff --git a/Blueberry/UpdateManager.cs b/Blueberry/UpdateManager.cs new file mode 100644 index 0000000..3e01c5a --- /dev/null +++ b/Blueberry/UpdateManager.cs @@ -0,0 +1,120 @@ +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Text.Json; + +namespace Blueberry +{ + public class UpdateManager + { + private const string releaseUrl = "https://git.technopunk.space/api/v1/repos/tomi/Blueberry/releases/latest"; + private const string CurrentVersion = "0.1.0"; + + public async Task CheckAndInstallAsync() + { + using var client = new HttpClient(); + + client.DefaultRequestHeaders.Add("User-Agent", "Blueberry-Updater"); + + try + { + var json = client.GetStringAsync(releaseUrl).ConfigureAwait(false).GetAwaiter().GetResult(); + var release = JsonSerializer.Deserialize(json); + + if (release == null) + throw new NullReferenceException(); + + if (release.tag_name != CurrentVersion) + { + var file = release.assets.Find(x => x.name.Contains(".zip")) ?? throw new NullReferenceException(); + string downloadUrl = file.browser_download_url; + await PerformUpdate(client, downloadUrl); + } + } + catch (Exception) + { + + } + } + + private async Task PerformUpdate(HttpClient client, string url) + { + string tempZip = Path.Combine(Path.GetTempPath(), "blueberry_update.zip"); + string currentExe = Process.GetCurrentProcess().MainModule.FileName; + string appDir = AppDomain.CurrentDomain.BaseDirectory; + + // 1. Download + var data = await client.GetByteArrayAsync(url); + File.WriteAllBytes(tempZip, data); + + // 2. Create a temporary batch script to handle the swap + // We use a small delay (timeout) to allow the main app to close fully + string psScript = $@" +# Wait for the main app to close completely +Start-Sleep -Seconds 2 + +$exePath = '{currentExe}' +$zipPath = '{tempZip}' +$destDir = '{appDir}' + +# Retry logic for deletion (in case antivirus or OS holds the lock) +$maxRetries = 10 +$retryCount = 0 + +while ($retryCount -lt $maxRetries) {{ + try {{ + # Attempt to delete the old executable + if (Test-Path $exePath) {{ Remove-Item $exePath -Force -ErrorAction Stop }} + break # If successful, exit loop + }} + catch {{ + Start-Sleep -Milliseconds 500 + $retryCount++ + }} +}} + +# Unzip the new version +Expand-Archive -Path $zipPath -DestinationPath $destDir -Force + +# CLEANUP: Delete the zip +Remove-Item $zipPath -Force + +# RESTART: Launch the new executable +# 'Start-Process' is the robust way to launch detached processes in PS +Start-Process -FilePath $exePath -WorkingDirectory $destDir + +# SELF-DESTRUCT: Remove this script +Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force +"; + string psPath = Path.Combine(Path.GetTempPath(), "blueberry_updater.ps1"); + File.WriteAllText(psPath, psScript); + + // 3. Execute the PowerShell script hidden + var startInfo = new ProcessStartInfo() + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{psPath}\"", + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden + }; + + Process.Start(startInfo); + + // 4. Kill the current app immediately so the script can delete it + System.Windows.Application.Current.Shutdown(); + } + + public class Root + { + public string tag_name { get; set; } + public List assets { get; set; } + } + + public class Asset + { + public string name { get; set; } + public string browser_download_url { get; set; } + } + } +} diff --git a/BlueberryUpdater/BlueberryUpdater.csproj b/BlueberryUpdater/BlueberryUpdater.csproj deleted file mode 100644 index e76b315..0000000 --- a/BlueberryUpdater/BlueberryUpdater.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - Exe - net8.0-windows10.0.17763.0 - enable - enable - app.manifest - bb.ico - - - - true - - true - true - true - - true - false - false - - - - - PreserveNewest - - - - diff --git a/BlueberryUpdater/Program.cs b/BlueberryUpdater/Program.cs deleted file mode 100644 index 9f9bdf8..0000000 --- a/BlueberryUpdater/Program.cs +++ /dev/null @@ -1,246 +0,0 @@ -using System.Diagnostics; -using System.IO.Compression; - -namespace BlueberryUpdater -{ - internal class Program - { - static string appName = "Blueberry"; - static string updaterName = appName + "Updater"; - static string installPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), appName); - - static void Main(string[] args) - { - if (Directory.Exists(installPath) && File.Exists(Path.Combine(installPath, appName + ".exe"))) - Uninstall(); - else - Install(); - } - - private static void Uninstall() - { - Console.WriteLine("Would you like to uninstall Blueberry? [y/N]"); - var key = Console.ReadLine(); - if (key == null ||key.ToLower() != "y") - return; - - var appdata = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), appName); - - Console.WriteLine("Removing Blueberry..."); - var dirs = Directory.GetDirectories(installPath); - var files = Directory.GetFiles(installPath); - var total = dirs.Length + files.Length; - var i = 0; - - foreach (var dir in dirs) - { - i++; - Directory.Delete(dir, true); - DrawProgressBar((int)((double)i / total * 100), dir.Split('\\').Last()); - } - - foreach (var file in files) - { - i++; - if (file.Split('\\').Last() == updaterName + ".exe") - continue; - File.Delete(file); - i++; - DrawProgressBar((int)((double)i / total * 100), file.Split('\\').Last()); - } - Directory.Delete(appdata, true); - - DrawProgressBar(100, "Done"); - Console.WriteLine(); - Console.WriteLine("Uninstall Complete!"); - - Console.WriteLine("Press any key to exit..."); - Console.ReadKey(); - - SelfDelete(); - } - - private static void Install() - { - try - { - var client = new HttpClient(); - client.DefaultRequestHeaders.UserAgent.ParseAdd("BlueberryUpdater"); - - string releaseUrl = "https://git.technopunk.space/api/v1/repos/tomi/Blueberry/releases/latest"; - - // 1. Fetch JSON - var json = client.GetStringAsync(releaseUrl).ConfigureAwait(false).GetAwaiter().GetResult(); - json = json.Split("\"name\":\"payload.zip\",")[1]; - - - // 2. Find URL for "payload.zip" - string downloadUrl = json.Split("\"browser_download_url\":\"")[1].Split("\"")[0]; - - Console.WriteLine("Downloading Blueberry..."); - var stream = DownloadFileWithProgressAsync(client, downloadUrl).GetAwaiter().GetResult(); - - Console.WriteLine($"Installing to {installPath}..."); - - // 1. Clean existing install if necessary - if (Directory.Exists(installPath)) - { - Directory.Delete(installPath, true); - } - Directory.CreateDirectory(installPath); - - ExtractWithProgress(stream); - MoveUpdater(); - - Console.WriteLine(); - Console.WriteLine("Installation Complete!"); - - // Optional: Create Shortcut logic here - } - catch (Exception ex) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($"Error: {ex.Message}"); - } - - Console.WriteLine("Press any key to exit..."); - Console.ReadKey(); - } - - public static void SelfDelete() - { - string exePath = Process.GetCurrentProcess().MainModule.FileName; - string directoryToDelete = Path.GetDirectoryName(exePath); - - string args = $"/C timeout /t 2 /nobreak > Nul & del \"{exePath}\" & rmdir /q \"{directoryToDelete}\""; - - ProcessStartInfo psi = new ProcessStartInfo - { - FileName = "cmd.exe", - Arguments = args, - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - UseShellExecute = false - }; - - Process.Start(psi); - - Environment.Exit(0); - } - - public static void MoveUpdater() - { - string currentExe = Process.GetCurrentProcess().MainModule.FileName; - - var updaterPath = Path.Combine(installPath, updaterName + ".exe"); - - Directory.CreateDirectory(Path.GetDirectoryName(updaterPath)); - File.Copy(currentExe, updaterPath, overwrite: true); - } - - static void DrawProgressBar(int percent, string filename) - { - percent = Math.Clamp(percent, 0, 100); - - // Move cursor to start of line - Console.CursorLeft = 0; - - // Limit filename length for clean display - string shortName = filename.Length > 20 ? filename.Substring(0, 17) + "..." : filename.PadRight(20); - - Console.Write("["); - int width = Console.WindowWidth - 31; // Width of the bar - int progress = (int)((percent / 100.0) * width); - - // Draw filled part - Console.Write(new string('#', progress)); - // Draw empty part - Console.Write(new string('-', width - progress)); - - Console.Write($"] {percent}% {shortName} "); - } - - static async Task DownloadFileWithProgressAsync(HttpClient client, string url) - { - // 1. Get headers only first to check size - using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); - response.EnsureSuccessStatusCode(); - - var totalBytes = response.Content.Headers.ContentLength ?? -1L; - var canReportProgress = totalBytes != -1; - - using var contentStream = await response.Content.ReadAsStreamAsync(); - var memoryStream = new MemoryStream(); - - var buffer = new byte[8192]; - long totalRead = 0; - int bytesRead; - - while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length)) > 0) - { - await memoryStream.WriteAsync(buffer, 0, bytesRead); - totalRead += bytesRead; - - if (canReportProgress) - DrawProgressBar((int)((double)totalRead / totalBytes * 100), "Downloading..."); - } - DrawProgressBar(100, "Done"); - Console.WriteLine(); - return memoryStream; - } - static void ExtractWithProgress(MemoryStream stream) - { - stream.Position = 0; - using (ZipArchive archive = new(stream, ZipArchiveMode.Read)) - { - int totalEntries = archive.Entries.Count; - int currentEntry = 0; - - foreach (ZipArchiveEntry entry in archive.Entries) - { - currentEntry++; - - // Calculate percentage - int percent = (int)((double)currentEntry / totalEntries * 100); - - // Draw Progress Bar - DrawProgressBar(percent, entry.Name); - - // Create the full path - string destinationPath = Path.GetFullPath(Path.Combine(installPath, entry.FullName)); - - // Security check: prevent ZipSlip (writing outside target folder) - if (!destinationPath.StartsWith(installPath, StringComparison.OrdinalIgnoreCase)) - continue; - - // Handle folders vs files - if (string.IsNullOrEmpty(entry.Name)) // It's a directory - { - Directory.CreateDirectory(destinationPath); - } - else // It's a file - { - // Ensure the directory exists (zipped files might not list their dir first) - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); - entry.ExtractToFile(destinationPath, overwrite: true); - } - } - } - DrawProgressBar(100, "Done"); - Console.WriteLine(); - } - } - - public class Dto - { - public class Item - { - public Assets[] assets { get; set; } - } - public class Assets - { - public string name { get; set; } - public string browser_download_url { get; set; } - } - } -} diff --git a/BlueberryUpdater/app.manifest b/BlueberryUpdater/app.manifest deleted file mode 100644 index 438ee0d..0000000 --- a/BlueberryUpdater/app.manifest +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfa2379 --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# Blueberry + +A modern WPF desktop application for Redmine issue management and time tracking. Built with .NET 8 and WPF-UI for a sleek, fluent design experience. + +![Blueberry](/images/bb.ico) + +## Features + +### Core Functionality +- **Issue Management**: View, search, and manage your assigned Redmine issues +- **Time Tracking**: Log work hours with detailed comments and date selection +- **Real-time Metrics**: Track today's, yesterday's, and monthly hours with daily averages +- **Status Management**: Quick status updates for issues without leaving the app +- **Multi-language Support**: Hungarian interface with easy localization + +### Technical Features +- **Offline Caching**: Intelligent caching system for improved performance +- **Auto-updater**: Built-in updater for seamless application updates +- **Modern UI**: Fluent Design with WPF-UI components +- **Responsive Layout**: Adaptive interface that works on different screen sizes + +## Screenshots + +The main interface provides: +- **Left Panel**: Searchable issue list with project and status information +- **Right Panel**: Time entry form with calendar integration and metrics dashboard +- **Quick Actions**: Status updates, browser integration, and API configuration + +## Installation + +### Automatic Installation (Recommended) +1. Download the latest `BlueberryUpdater.exe` from the [Releases](https://git.technopunk.space/tomi/Blueberry/releases) page +2. Run the updater - it will automatically download and install the latest version +3. The application will be installed to `C:\Program Files\Blueberry` + +### Manual Installation +1. Download the latest `payload.zip` from the [Releases](https://git.technopunk.space/tomi/Blueberry/releases) page +2. Extract to your desired location +3. Run `Blueberry.exe` + +## Configuration + +### First-time Setup +1. Launch Blueberry +2. Click the "API kulcs..." button in the bottom-left corner +3. Enter your Redmine URL (e.g., `https://redmine.example.com`) +4. Enter your API key (get this from your Redmine account settings) +5. Click "Csatlakozás" to connect + +### API Key Location +In your Redmine instance: +1. Go to **My Account** → **API access key** +2. Copy your API key +3. Use the "API kulcs link" button in Blueberry to go directly there + +## Usage + +### Time Tracking +1. Select one or more dates using the calendar button +2. Enter the issue number (e.g., `65432`) +3. Enter hours worked (e.g., `0.25` for 15 minutes) +4. Add a descriptive comment +5. Click "Send" to log your time + +### Issue Management +- **Search**: Use the search box to filter issues by title or ID +- **Open in Browser**: Click the open button next to any issue to view it in your browser +- **Status Updates**: Select an issue and use "Státusz..." to change its status +- **Create New**: Use "Új jegy" to create new issues (requires project and tracker selection) + +### Metrics Dashboard +The top-right cards show: +- **Mai órák**: Hours logged today +- **Tegnapi órák**: Hours logged yesterday +- **Ehavi órák**: Total hours this month +- **Átlag per nap**: Daily average for the current month + +## Architecture + +### Project Structure +``` +Blueberry/ +├── Blueberry/ # Main WPF application +│ ├── MainWindow.xaml # Primary interface +│ ├── IssueWindow.xaml # Issue detail view +│ └── Resources/ # Custom fonts (Inter, Roboto, Zalando) +├── Blueberry.Redmine/ # Redmine API integration +│ ├── Dto/ # Data transfer objects +│ ├── RedmineApiClient.cs # HTTP client for Redmine API +│ ├── RedmineManager.cs # Business logic layer +│ └── RedmineCache.cs # Caching system +└── BlueberryUpdater/ # Auto-updater utility + └── Program.cs # Installation/update logic +``` + +### Technologies Used +- **.NET 8** - Latest .NET framework with modern features +- **WPF** - Windows Presentation Foundation for desktop UI +- **WPF-UI** - Modern Fluent Design components +- **Microsoft.Extensions** - Logging, HTTP, and hosting +- **System.Text.Json** - JSON serialization for API responses + +## Development + +### Prerequisites +- Visual Studio 2022 (17.14+) +- .NET 8.0 SDK +- Windows 10/11 (targeting Windows 10 17763+) + +### Building +```bash +git clone https://git.technopunk.space/tomi/Blueberry.git +cd Blueberry +dotnet build Blueberry.sln +``` + +### Running +```bash +dotnet run --project Blueberry/Blueberry.csproj +``` + +## Configuration Files + +The application stores configuration in: +- **AppData**: `%APPDATA%\Blueberry\` for user settings and cache +- **Installation**: `C:\Program Files\Blueberry\` for application files + +### Cache Files +- `Statuses.json` - Cached issue statuses +- `Priorities.json` - Cached priority levels +- `CustomFields.json` - Cached custom field definitions +- `Projects.json` - Cached project list + +## API Endpoints Used + +Blueberry integrates with Redmine's REST API: +- `/issues.json` - Issue listing and creation +- `/time_entries.json` - Time tracking +- `/users/current.json` - Current user info +- `/issue_statuses.json` - Available statuses +- `/projects.json` - Project listing +- `/custom_fields.json` - Custom field definitions + +## Troubleshooting + +### Connection Issues +- Verify your Redmine URL is accessible +- Check that your API key is valid and has proper permissions +- Ensure your Redmine instance has the REST API enabled + +### Performance Issues +- Cache is automatically refreshed every hour +- Use the "Frissítés" button to manually refresh data +- Large issue lists may take time to load initially + +### Updater Issues +- Run BlueberryUpdater.exe as administrator if installation fails +- Check your internet connection for downloading updates +- Temporarily disable antivirus if it blocks the installation + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Support + +For issues and feature requests: +- Create an issue in the [repository](https://git.technopunk.space/tomi/Blueberry/issues) +- Check existing issues for known problems +- Include your Redmine version and Blueberry version in bug reports + +## Changelog + +### Latest Version +- Improved caching performance +- Enhanced UI responsiveness +- Bug fixes for time entry validation + +### Previous Versions +See the [Releases](https://git.technopunk.space/tomi/Blueberry/releases) page for detailed version history. + +--- + +**Blueberry** - Making Redmine time tracking and issue management effortless on Windows. \ No newline at end of file diff --git a/BlueberryUpdater/bb.ico b/images/bb.ico similarity index 100% rename from BlueberryUpdater/bb.ico rename to images/bb.ico diff --git a/installer.ps1 b/installer.ps1 new file mode 100644 index 0000000..3aafb9f --- /dev/null +++ b/installer.ps1 @@ -0,0 +1,21 @@ +$installDir = "$env:LOCALAPPDATA\Blueberry" +$zipUrl = "https://git.technopunk.space/tomi/Blueberry/releases/download/latest/Blueberry.zip" +$exePath = "$installDir\Blueberry.exe" + +if (Test-Path $installDir) { Remove-Item $installDir -Recurse -Force } +New-Item -ItemType Directory -Path $installDir -Force | Out-Null + +# 3. Download & Unzip +Write-Host "Downloading Blueberry..." +$zipFile = "$env:TEMP\Blueberry.zip" +Invoke-WebRequest -Uri $zipUrl -OutFile $zipFile +Expand-Archive -Path $zipFile -DestinationPath $installDir -Force +Remove-Item $zipFile + +$wsh = New-Object -ComObject WScript.Shell +$shortcut = $wsh.CreateShortcut("$env:USERPROFILE\Desktop\Blueberry.lnk") +$shortcut.TargetPath = $exePath +$shortcut.Save() + +# 5. Run it +Start-Process $exePath \ No newline at end of file