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
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.
+
+
+
+## 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