complete refactor

This commit is contained in:
2025-12-15 09:26:27 +01:00
parent 41c7ec292c
commit fbf3b6826c
45 changed files with 3001 additions and 1474 deletions

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -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<PossibleValue> PossibleValues { get; set; }
[JsonPropertyName("trackers")]
public List<Tracker> Trackers { get; set; }
[JsonPropertyName("roles")]
public List<Role> 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<CustomField> 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.

View File

@@ -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<CustomField> 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<Journal> 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<Detail> 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.

View File

@@ -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; }
}
}

View File

@@ -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<CustomField> 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<Issue> 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.

View File

@@ -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<CustomField> 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.

View File

@@ -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<IssuePriority> 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.

View File

@@ -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<CustomField> 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<Project> 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.

View File

@@ -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<CustomField> CustomFields { get; set; }
[JsonPropertyName("trackers")]
public List<Tracker> 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.

View File

@@ -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<IssueStatus> 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.

View File

@@ -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<CustomField> 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.

View File

@@ -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<TimeEntry> 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<CustomField> 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.

View File

@@ -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<RedmineApiClient> _logger;
readonly HttpClient _httpClient;
public RedmineApiClient(RedmineConfig config, ILogger<RedmineApiClient> logger, HttpClient httpClient)
{
_config = config;
_logger = logger;
_httpClient = httpClient;
}
private async Task<TResponse?> SendRequestAsync<TResponse>(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<TResponse>(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<List<TReturn>> SendRequestWithPagingAsync<TResponse, TReturn>(HttpMethod method, string endpoint, int limit, Func<TResponse, List<TReturn>> itemParser,
IProgress<(int current, int total)>? progress = null, object? payload = null, CancellationToken? token = null) where TResponse : IResponseList
{
var offset = 0;
List<TReturn> returnList = [];
while (true)
{
var path = $"{endpoint}&limit={limit}&offset={offset}";
var responseList = await SendRequestAsync<TResponse>(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<List<StatusList.IssueStatus>> GetStatusesAsync(CancellationToken? token = null)
{
var path = "issue_statuses.json";
var statusList = await SendRequestAsync<StatusList.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return statusList.IssueStatuses;
}
public async Task<List<CustomFieldList.CustomField>> GetCustomFieldsAsync(CancellationToken? token = null)
{
var path = "custom_fields.json";
var fields = await SendRequestAsync<CustomFieldList.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return fields.CustomFields;
}
public async Task<List<PriorityList.IssuePriority>> GetPrioritiesAsync(CancellationToken? token = null)
{
var path = "enumerations/issue_priorities.json";
var fields = await SendRequestAsync<PriorityList.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return fields.IssuePriorities;
}
public async Task<List<IssueList.Issue>> 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<IssueList.Root, IssueList.Issue>(HttpMethod.Get, path, limit, (x) => x.Issues,
progress, token: token);
return items;
}
public async Task<List<ProjectList.Project>> GetProjects(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
{
var path = $"projects.json";
var items = await SendRequestWithPagingAsync<ProjectList.Root, ProjectList.Project>(HttpMethod.Get, path, limit, (x) => x.Projects,
progress, token: token);
return items;
}
public async Task<List<ProjectTrackers.Tracker>> GetTrackersForProject(string projectId, CancellationToken? token = null)
{
var path = $"projects/{projectId}.json?include=trackers";
var trackers = await SendRequestAsync<ProjectTrackers.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return trackers.Project.Trackers;
}
public async Task<double> 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<UserTime.Root, UserTime.TimeEntry>(HttpMethod.Get, path, limit, (x)=> x.TimeEntries, progress, token: token);
var hours = timedata.Sum(x => x.Hours);
return hours;
}
public async Task<DetailedIssue.Issue> GetIssue(int issueId, CancellationToken? token = null)
{
var path = $"issues/{issueId}.json?include=journals";
var issue = await SendRequestAsync<DetailedIssue.Root>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return issue.Issue;
}
public async Task<IssueList.Issue> GetSimpleIssue(int issueId, CancellationToken? token = null)
{
var path = $"issues/{issueId}.json?include=journals";
var issue = await SendRequestAsync<IssueList.SimpleRoot>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
return issue.Issue;
}
public async Task<UserInfo.User> GetUserAsync(int? userId = null, CancellationToken? token = null)
{
var path = "users/current.json";
if (userId != null)
path = $"users/{userId}.json";
var user = await SendRequestAsync<UserInfo.Root>(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<object>(HttpMethod.Put, path, payload, token: token);
}
public async Task<int> CreateNewIssue(int projectId, int trackerId, string subject, string description,
double estimatedHours, int priorityId, int? assigneeId = null, int? parentIssueId = null, CancellationToken? token = null)
{
var path = "issues.json";
var payload = new
{
project_id = projectId,
tracker_id = trackerId,
subject = subject,
description = description,
priority_id = priorityId,
assigned_to_id = assigneeId,
parent_issue_id = parentIssueId,
custom_fields = new // TODO, do something about this
{
id = 65,
value = estimatedHours
}
};
var issue = await SendRequestAsync<NewIssue.Root>(HttpMethod.Post, path, payload, token)
?? throw new NullReferenceException();
return issue.Issue.Id;
}
public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null, CancellationToken? token = null)
{
string url = $"time_entries.json";
string dateString = (date ?? DateTime.Now).ToString("yyyy-MM-dd");
var payload = new
{
time_entry = new
{
issue_id = issueId,
hours = hours,
comments = comments,
spent_on = dateString,
activity_id = activityId
}
};
await SendRequestAsync<object>(HttpMethod.Post, url, payload, token: token);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace BlueMine.Redmine
namespace Blueberry.Redmine
{
public class RedmineApiException : Exception
{

View File

@@ -0,0 +1,208 @@
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace Blueberry.Redmine
{
class RedmineCache<T>
{
private List<T> Items { get; set; } = [];
public DateTime LastUpdated { get; set; } = DateTime.MinValue;
private readonly ILogger _logger;
private readonly TimeSpan _cacheDuration;
private readonly object _lock = new();
private int _maxCapacity = int.MaxValue;
private bool _useSlidingExpiration = false;
private DateTime _lastAccessed = DateTime.MinValue;
private string? _cacheFilePath;
private Func<Task<List<T>>>? _refreshCallback;
private class CacheData
{
public List<T> Items { get; set; } = [];
public DateTime LastUpdated { get; set; }
public DateTime LastAccessed { get; set; }
}
/// <summary>
/// Initializes a new instance of the RedmineCache class.
/// </summary>
/// <param name="cacheDuration">The time span for which the cache remains valid. After this duration, the cache is considered expired.</param>
/// <param name="logger">The logger instance for logging cache operations.</param>
/// <param name="maxCapacity">The maximum number of items to store in the cache. Defaults to int.MaxValue (unlimited).</param>
/// <param name="useSlidingExpiration">If true, the cache expiration resets on each access (sliding expiration). If false, uses absolute expiration from the last update. Defaults to false.</param>
/// <param name="cacheFilePath">Optional file path for persisting the cache to disk. If provided, the cache loads from and saves to this file. Defaults to null (no persistence).</param>
/// <param name="refreshCallback">Optional asynchronous callback function to refresh the cache when it expires. Called automatically in GetItemsAsync if the cache is invalid. Defaults to null.</param>
public RedmineCache(TimeSpan cacheDuration, ILogger<RedmineCache<T>> logger, int maxCapacity = int.MaxValue, bool useSlidingExpiration = false,
string? cacheFilePath = null, Func<Task<List<T>>>? refreshCallback = null)
{
if (logger == null) throw new ArgumentNullException(nameof(logger));
if (cacheDuration <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(cacheDuration));
_logger = logger;
_cacheDuration = cacheDuration;
_maxCapacity = maxCapacity;
_useSlidingExpiration = useSlidingExpiration;
_cacheFilePath = cacheFilePath;
_refreshCallback = refreshCallback;
LoadFromFile();
}
/// <summary>
/// Initializes a new instance of the RedmineCache class with cache duration in seconds.
/// </summary>
/// <param name="cacheDurationSec">The cache duration in seconds. Converted to a TimeSpan internally.</param>
/// <param name="logger">The logger instance for logging cache operations.</param>
/// <param name="maxCapacity">The maximum number of items to store in the cache. Defaults to int.MaxValue (unlimited).</param>
/// <param name="useSlidingExpiration">If true, the cache expiration resets on each access (sliding expiration). If false, uses absolute expiration from the last update. Defaults to false.</param>
/// <param name="cacheFilePath">Optional file path for persisting the cache to disk. If provided, the cache loads from and saves to this file. Defaults to null (no persistence).</param>
/// <param name="refreshCallback">Optional asynchronous callback function to refresh the cache when it expires. Called automatically in GetItemsAsync if the cache is invalid. Defaults to null.</param>
public RedmineCache(int cacheDurationSec, ILogger<RedmineCache<T>> logger, int maxCapacity = int.MaxValue, bool useSlidingExpiration = false,
string? cacheFilePath = null, Func<Task<List<T>>>? refreshCallback = null)
: this(new TimeSpan(0, 0, cacheDurationSec), logger, maxCapacity, useSlidingExpiration, cacheFilePath, refreshCallback) { }
private void LoadFromFile()
{
if (_cacheFilePath == null || !File.Exists(_cacheFilePath)) return;
try
{
var json = File.ReadAllText(_cacheFilePath);
var data = JsonSerializer.Deserialize<CacheData>(json);
if (data != null)
{
Items = data.Items ?? [];
LastUpdated = data.LastUpdated;
_lastAccessed = data.LastAccessed;
}
_logger.LogDebug("Loaded cache from {path}", _cacheFilePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load cache from {path}", _cacheFilePath);
}
}
private void SaveToFile()
{
if (_cacheFilePath == null) return;
try
{
var data = new CacheData { Items = Items, LastUpdated = LastUpdated, LastAccessed = _lastAccessed };
var json = JsonSerializer.Serialize(data);
var dir = Path.GetDirectoryName(_cacheFilePath) ?? throw new NullReferenceException();
Directory.CreateDirectory(dir);
File.WriteAllText(_cacheFilePath, json);
_logger.LogDebug("Saved cache to {path}", _cacheFilePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save cache to {path}", _cacheFilePath);
}
}
private void RefreshCache(List<T> newItems)
{
_logger.LogDebug("Refreshing cache with {count} items", newItems.Count);
lock (_lock)
{
Items = newItems.Count > _maxCapacity ? newItems.Take(_maxCapacity).ToList() : newItems;
LastUpdated = DateTime.UtcNow;
_lastAccessed = DateTime.UtcNow;
SaveToFile();
_logger.LogDebug("Cache refreshed");
}
}
public void InvalidateCache()
{
_logger.LogDebug("Invalidating cache");
lock (_lock)
{
LastUpdated = DateTime.MinValue;
_lastAccessed = DateTime.MinValue;
Items.Clear();
SaveToFile();
_logger.LogDebug("Cache invalidated");
}
}
public bool IsCacheValid()
{
lock (_lock)
{
bool valid = !_useSlidingExpiration ? DateTime.UtcNow - LastUpdated <= _cacheDuration : DateTime.UtcNow - _lastAccessed <= _cacheDuration;
_logger.LogDebug("Cache valid: {valid}", valid);
return valid;
}
}
private IReadOnlyList<T> GetItems()
{
lock (_lock)
{
_lastAccessed = DateTime.UtcNow;
_logger.LogDebug("Returning {count} cached items", Items.Count);
return Items.AsReadOnly();
}
}
public Task RefreshCacheAsync(List<T> newItems) => Task.Run(() => RefreshCache(newItems));
public async Task<IReadOnlyList<T>> GetItemsAsync()
{
bool needsRefresh = false;
lock (_lock)
{
_lastAccessed = DateTime.UtcNow;
if (!IsCacheValid() && _refreshCallback != null)
{
needsRefresh = true;
}
}
if (needsRefresh)
{
await TryRefreshAsync();
}
lock (_lock)
{
_logger.LogDebug("Returning {count} cached items", Items.Count);
return Items.AsReadOnly();
}
}
public bool IsEmpty()
{
lock (_lock)
{
return Items.Count == 0;
}
}
public int GetCount()
{
lock (_lock)
{
return Items.Count;
}
}
public async Task TryRefreshAsync()
{
if (_refreshCallback != null)
{
try
{
var newItems = await _refreshCallback();
RefreshCache(newItems);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh cache via callback");
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
namespace BlueMine.Redmine
namespace Blueberry.Redmine
{
public class RedmineConfig
{
@@ -8,5 +8,7 @@ namespace BlueMine.Redmine
public TimeSpan IssueCacheDuration { get; set; } = TimeSpan.FromMinutes(5);
public int MaxRetries { get; set; } = 3;
public int ConcurrencyLimit { get; set; } = 10;
public string CacheFilePath { get; set; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Blueberry", "Cache", "Redmine");
public bool IsInitiating { get; set; } = false;
}
}

View File

@@ -0,0 +1,167 @@
using Blueberry.Redmine.Dto;
using Microsoft.Extensions.Logging;
namespace Blueberry.Redmine
{
public class RedmineManager
{
private readonly TimeSpan DEFAULT_CACHE_DURATION = TimeSpan.FromHours(1);
private readonly RedmineConfig _config;
private readonly ILogger _logger;
private readonly RedmineApiClient _apiClient;
private readonly RedmineCache<StatusList.IssueStatus> _statusCache;
private readonly RedmineCache<PriorityList.IssuePriority> _priorityCache;
private readonly RedmineCache<CustomFieldList.CustomField> _customFieldCache;
private readonly RedmineCache<ProjectList.Project> _projectCache;
public RedmineManager(RedmineConfig config, HttpClient client, ILoggerFactory loggerFactory)
{
_config = config;
_apiClient = new RedmineApiClient(config, loggerFactory.CreateLogger<RedmineApiClient>(), client);
_statusCache = new RedmineCache<StatusList.IssueStatus>(
DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger<RedmineCache<StatusList.IssueStatus>>(), cacheFilePath: $"{_config.CacheFilePath}Statuses.json");
_priorityCache = new RedmineCache<PriorityList.IssuePriority>(
DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger<RedmineCache<PriorityList.IssuePriority>>(), cacheFilePath: $"{_config.CacheFilePath}Priorities.json");
_customFieldCache = new RedmineCache<CustomFieldList.CustomField>(
DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger<RedmineCache<CustomFieldList.CustomField>>(), cacheFilePath: $"{_config.CacheFilePath}CustomFields.json");
_projectCache = new RedmineCache<ProjectList.Project>(
DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger<RedmineCache<ProjectList.Project>>(), cacheFilePath: $"{_config.CacheFilePath}Projects.json");
_logger = loggerFactory.CreateLogger<RedmineManager>();
_logger.LogDebug("Initialized caches");
}
public async Task<bool> IsRedmineAvailable(CancellationToken? token = null)
{
try
{
await _apiClient.GetUserAsync();
return true;
}
catch (Exception)
{
return false;
}
}
public async Task<IReadOnlyList<StatusList.IssueStatus>> GetStatusesAsync(CancellationToken? token = null)
{
if (_statusCache.IsCacheValid())
{
return await _statusCache.GetItemsAsync();
}
var statuses = await _apiClient.GetStatusesAsync(token);
await _statusCache.RefreshCacheAsync(statuses);
return statuses;
}
public async Task<IReadOnlyList<PriorityList.IssuePriority>> GetPrioritiesAsync(CancellationToken? token = null)
{
if (_priorityCache.IsCacheValid())
{
return await _priorityCache.GetItemsAsync();
}
var priorities = await _apiClient.GetPrioritiesAsync(token);
await _priorityCache.RefreshCacheAsync(priorities);
return priorities;
}
public async Task<IReadOnlyList<CustomFieldList.CustomField>> GetCustomFieldsAsync(CancellationToken? token = null)
{
if (_customFieldCache.IsCacheValid())
{
return await _customFieldCache.GetItemsAsync();
}
var fields = await _apiClient.GetCustomFieldsAsync(token);
await _customFieldCache.RefreshCacheAsync(fields);
return fields;
}
public async Task<IReadOnlyList<ProjectList.Project>> GetProjectsAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
{
if (_projectCache.IsCacheValid())
{
return await _projectCache.GetItemsAsync();
}
var projects = await _apiClient.GetProjects(limit, progress, token);
await _projectCache.RefreshCacheAsync(projects);
return projects;
}
public async Task<UserInfo.User> GetCurrentUserAsync(CancellationToken? token = null)
{
var user = await _apiClient.GetUserAsync(token: token);
return user;
}
public async Task<UserInfo.User> GetUserAsync(int userId, CancellationToken? token = null)
{
var user = await _apiClient.GetUserAsync(userId, token: token);
return user;
}
public async Task<List<IssueList.Issue>> GetCurrentUserIssuesAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
{
var user = await GetCurrentUserAsync(token);
return await _apiClient.GetOpenIssuesByAssignee(user.Id, limit, progress, token);
}
public async Task<double> GetCurrentUserTimeAsync(DateTime start, DateTime end, int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
{
var user = await GetCurrentUserAsync(token);
return await _apiClient.GetTotalTimeForUser(user.Id, start, end, limit, progress, token);
}
public async Task<DetailedIssue.Issue> GetIssueAsync(int issueId, CancellationToken? token = null)
{
return await _apiClient.GetIssue(issueId, token);
}
public async Task<IssueList.Issue> GetSimpleIssueAsync(int issueId, CancellationToken? token = null)
{
return await _apiClient.GetSimpleIssue(issueId, token);
}
public async Task<List<ProjectTrackers.Tracker>> GetProjectTrackersAsync(int projectId, CancellationToken? token = null)
{
return await _apiClient.GetTrackersForProject(projectId.ToString(), token);
}
public async Task SetIssueStatusAsync(int issueId, int statusId, CancellationToken? token = null)
{
await _apiClient.SetIssueStatus(issueId, statusId, token);
}
public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null, CancellationToken? token = null)
{
await _apiClient.LogTimeAsync(issueId, hours, comments, date, activityId, token);
}
public async Task<int> CreateIssueAsync(int projectId, int trackerId, string subject, string description,
double estimatedHours, int priorityId, int? assigneeId = null, int? parentIssueId = null, CancellationToken? token = null)
{
return await _apiClient.CreateNewIssue(projectId, trackerId, subject, description, estimatedHours, priorityId, assigneeId, parentIssueId, token);
}
public async Task<double> GetCurrentUserTimeTodayAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
{
return await GetCurrentUserTimeAsync(DateTime.Today, DateTime.Today, limit, progress, token);
}
public async Task<double> GetCurrentUserTimeYesterdayAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
{
return await GetCurrentUserTimeAsync(DateTime.Today.AddDays(-1), DateTime.Today.AddDays(-1), limit, progress, token);
}
public async Task<double> GetCurrentUserTimeThisMonthAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
{
var start = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
var end = start.AddMonths(1).AddDays(-1);
return await GetCurrentUserTimeAsync(start, end, limit, progress, token);
}
}
}

View File

@@ -1,16 +1,11 @@
using System.Text;
using BlueMine.Redmine;
using System.IO;
using System.Security.Cryptography; // For encryption
using System.Text.Json;
namespace BlueMine
namespace Blueberry.Redmine
{
public class SettingsManager
public class RedmineSettingsManager
{
// Save to: C:\Users\Username\AppData\Roaming\YourAppName\settings.json
private readonly string _filePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Blueberry",
@@ -19,7 +14,10 @@ namespace BlueMine
public RedmineConfig Load()
{
if (!File.Exists(_filePath))
return new RedmineConfig();
return new RedmineConfig()
{
IsInitiating = true,
};
try
{
@@ -28,7 +26,6 @@ namespace BlueMine
if(config == null)
return new RedmineConfig();
// Decrypt the API Key if it exists
if (!string.IsNullOrEmpty(config.ApiKey))
{
config.ApiKey = Unprotect(config.ApiKey);
@@ -37,34 +34,30 @@ namespace BlueMine
}
catch
{
// If file is corrupted, return default
return new RedmineConfig();
}
}
public void Save(RedmineConfig config)
{
// Create directory if it doesn't exist
Directory.CreateDirectory(Path.GetDirectoryName(_filePath) ?? throw new NullReferenceException("Config directory path creation failed."));
// Create a copy to encrypt so we don't mess up the runtime object
var copy = new RedmineConfig
{
RedmineUrl = config.RedmineUrl,
// Encrypt the key before saving
ApiKey = Protect(config.ApiKey),
ProjectCacheDuration = config.ProjectCacheDuration,
// ... copy other fields
CacheFilePath = config.CacheFilePath,
ConcurrencyLimit = config.ConcurrencyLimit,
IssueCacheDuration = config.IssueCacheDuration,
MaxRetries = config.MaxRetries,
IsInitiating = false
};
var json = JsonSerializer.Serialize(copy, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_filePath, json);
}
// --- ENCRYPTION HELPERS (DPAPI) ---
// This encrypts data using the current user's Windows credentials.
// Only this user on this machine can decrypt it.
private string Protect(string clearText)
{
if (string.IsNullOrEmpty(clearText)) return "";

View File

@@ -1,11 +1,11 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36603.0
# Visual Studio Version 18
VisualStudioVersion = 18.1.11304.174 d18.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blueberry", "Blueberry\Blueberry.csproj", "{201018E0-4328-4B0A-8BD7-0E3AC6155A68}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlueberryUpdater", "BlueberryUpdater\BlueberryUpdater.csproj", "{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blueberry.Redmine", "Blueberry.Redmine\Blueberry.Redmine.csproj", "{54CFDEC5-3E7B-4B9F-B76C-B0E742729624}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -17,10 +17,10 @@ Global
{201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Debug|Any CPU.Build.0 = Debug|Any CPU
{201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Release|Any CPU.ActiveCfg = Release|Any CPU
{201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Release|Any CPU.Build.0 = Release|Any CPU
{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Release|Any CPU.Build.0 = Release|Any CPU
{54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.Debug|Any CPU.Build.0 = Debug|Any CPU
{54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.Release|Any CPU.ActiveCfg = Release|Any CPU
{54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -1,4 +1,5 @@
using BlueMine.Redmine;
using Blueberry;
using Blueberry.Redmine;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -23,11 +24,11 @@ namespace BlueMine
.ConfigureAppConfiguration(c => { c.SetBasePath(Path.GetDirectoryName(AppContext.BaseDirectory) ?? throw new NullReferenceException()); })
.ConfigureServices((context, services) =>
{
services.AddSingleton<SettingsManager>();
services.AddSingleton<RedmineSettingsManager>();
services.AddSingleton(sp =>
{
var manager = sp.GetRequiredService<SettingsManager>();
var manager = sp.GetRequiredService<RedmineSettingsManager>();
return manager.Load();
});
@@ -52,6 +53,9 @@ namespace BlueMine
/// </summary>
private async void OnStartup(object sender, StartupEventArgs e)
{
var update = new UpdateManager();
await update.CheckAndInstallAsync();
await _host.StartAsync();
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
mainWindow.Show();

View File

@@ -40,4 +40,8 @@
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Blueberry.Redmine\Blueberry.Redmine.csproj" />
</ItemGroup>
</Project>

View File

@@ -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",

144
Blueberry/IssueWindow.xaml Normal file
View File

@@ -0,0 +1,144 @@
<ui:FluentWindow x:Class="Blueberry.IssueWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:local="clr-namespace:Blueberry"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance local:IssueWindow}"
Loaded="FluentWindow_Loaded"
Title="IssueWindow" Height="500" Width="900">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="1*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="2*" />
</Grid.ColumnDefinitions>
<ui:TitleBar Grid.ColumnSpan="2" />
<Grid x:Name="content" Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="1*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ui:TextBlock Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" x:Name="iSubjectTextBox" TextWrapping="Wrap" Margin="10, 5, 5, 5" FontWeight="Bold" FontSize="20" Text="Subject" />
<ui:TextBlock Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" x:Name="iProjectTextBox" TextWrapping="Wrap" Margin="10, 5, 5, 5" Text="Project" />
<ui:Card Grid.Row="2" Grid.Column="0" Margin="10, 0, 5, 5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="3*" />
</Grid.RowDefinitions>
<ui:TextBlock Grid.Row="0" Margin="0, -10, 0, -2" Text="Feladás" FontSize="10" HorizontalAlignment="Center" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock Grid.Row="1" x:Name="iCreatedTextBox" Margin="0, -2, 0, -10" Text="1970-01-01" HorizontalAlignment="Center" />
</Grid>
</ui:Card>
<ui:Card Grid.Row="2" Grid.Column="1" Margin="5, 0, 5, 5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="3*" />
</Grid.RowDefinitions>
<ui:TextBlock Grid.Row="0" Margin="0, -10, 0, -2" Text="Utoljára módosítva" FontSize="10" HorizontalAlignment="Center" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock Grid.Row="1" x:Name="iUpdatedTextBox" Margin="0, -2, 0, -10" Text="1970-01-01" HorizontalAlignment="Center" />
</Grid>
</ui:Card>
<ui:Card Grid.Row="2" Grid.Column="2" Margin="5, 0, 5, 5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="3*" />
</Grid.RowDefinitions>
<ui:TextBlock Grid.Row="0" Margin="0, -10, 0, -2" Text="Eltöltött idő" FontSize="10" HorizontalAlignment="Center" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock Grid.Row="1" x:Name="iSpentTimeTextBox" Margin="0, -2, 0, -10" Text="0.0" HorizontalAlignment="Center" />
</Grid>
</ui:Card>
<ui:Card Grid.Row="3" Grid.Column="0" Margin="10, 5, 5, 10" Grid.ColumnSpan="3" VerticalAlignment="Stretch" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ui:TextBlock x:Name="iDescriptionTextBox" TextWrapping="Wrap" Text="Description" HorizontalAlignment="Left" VerticalAlignment="Top" />
</ScrollViewer>
</ui:Card>
</Grid>
<Grid Grid.Row="1" Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="1*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<ui:ToggleSwitch x:Name="detailsToggleSwitch" HorizontalAlignment="Left"
OnContent="Részletek mutatva" OffContent="Részletek elrejtve" Checked="detailsToggleSwitch_Checked" Unchecked="detailsToggleSwitch_Checked" />
<ui:Button x:Name="openBrowserButton" Grid.Column="1" HorizontalAlignment="Right" Margin="10" Padding="5" Content="Böngésző..." Click="openBrowserButton_Click" />
<ui:Card x:Name="journals" Grid.Row="1" Grid.ColumnSpan="2" VerticalAlignment="Stretch" Margin="5, 10, 10, 10"
HorizontalAlignment="Stretch" VerticalContentAlignment="Stretch" HorizontalContentAlignment="Stretch">
<Grid>
<ui:ProgressRing x:Name="journalProgressRing" IsIndeterminate="True" Width="50" Height="50" />
<ui:ListView ItemsSource="{Binding Journals}" Grid.IsSharedSizeScope="True" ScrollViewer.HorizontalScrollBarVisibility="Disabled" VirtualizingPanel.ScrollUnit="Pixel">
<ui:ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="Focusable" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<ContentPresenter />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ui:ListView.ItemContainerStyle>
<ui:ListView.ItemTemplate>
<DataTemplate>
<ui:Card Margin="5, 0, 5, 0">
<ui:Card.Style>
<Style TargetType="ui:Card" BasedOn="{StaticResource {x:Type ui:Card}}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsData}" Value="True">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</DataTrigger>
</Style.Triggers>
</Style>
</ui:Card.Style>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="1*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="x" />
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="Auto" SharedSizeGroup="y" />
</Grid.ColumnDefinitions>
<Rectangle Grid.Row="0" Height="4" Margin="0, 0, 0, 4" RadiusX="2" RadiusY="2" HorizontalAlignment="Stretch" Fill="{Binding NameColor}" />
<ui:TextBlock Grid.Row="1" Grid.Column="0" Text="{Binding User}" FontSize="10" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" FontWeight="Bold" />
<ui:TextBlock Grid.Row="1" Grid.Column="2" Text="{Binding Date}" FontSize="10" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock Grid.Row="2" Grid.ColumnSpan="3" Text="{Binding Content}" Foreground="{Binding StatusColor}" TextWrapping="Wrap" FontSize="12" />
</Grid>
</ui:Card>
</DataTemplate>
</ui:ListView.ItemTemplate>
</ui:ListView>
</Grid>
</ui:Card>
</Grid>
</Grid>
</ui:FluentWindow>

View File

@@ -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
{
/// <summary>
/// Interaction logic for IssueWindow.xaml
/// </summary>
public partial class IssueWindow : FluentWindow
{
private readonly DetailedIssue.Issue _issue;
private readonly RedmineManager _manager;
private readonly RedmineConfig _config;
private readonly List<JournalDisplay> _journalDisplays = [];
public ObservableCollection<JournalDisplay> 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<List<JournalDisplay>> ProcessJournal(IEnumerable<DetailedIssue.Journal> journals)
{
var js = new List<JournalDisplay>();
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);
}
}
}

View File

@@ -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">
<ui:FluentWindow.Resources>
<FontFamily x:Key="Roboto">/Resources/Roboto.ttf</FontFamily>
<FontFamily x:Key="Zalando">/Resources/Zalando.ttf</FontFamily>
@@ -35,29 +35,50 @@
</ui:TitleBar.Icon>
</ui:TitleBar>
<Grid Grid.Row="1" Grid.Column="1">
<Grid x:Name="MetricDisplay" Grid.Row="1" Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<ui:Card Margin="10, 10, 5, 10">
<StackPanel Orientation="Vertical">
<ui:TextBlock Text="Mai órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="todayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="3*" />
</Grid.RowDefinitions>
<ui:ProgressRing x:Name="todayProgressRing" Grid.Row="0" Grid.RowSpan="2" Grid.Column="0" Width="10" Height="10" VerticalAlignment="Center" HorizontalAlignment="Left" IsIndeterminate="True" Visibility="Hidden" />
<ui:TextBlock Grid.Row="0" Grid.Column="0" Text="Mai órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock Grid.Row="1" Grid.Column="0" x:Name="todayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</Grid>
</ui:Card>
<ui:Card Grid.Column="1" Margin="5, 10, 5, 10">
<StackPanel Orientation="Vertical">
<ui:TextBlock Text="Tegnapi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="yesterdayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="3*" />
</Grid.RowDefinitions>
<ui:ProgressRing x:Name="yesterdayProgressRing" Grid.Row="0" Grid.RowSpan="2" Width="10" Height="10" VerticalAlignment="Center" HorizontalAlignment="Left" IsIndeterminate="True" Visibility="Hidden" />
<ui:TextBlock Grid.Row="0" Grid.Column="0" Text="Tegnapi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock Grid.Row="1" Grid.Column="0" x:Name="yesterdayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</Grid>
</ui:Card>
<ui:Card Grid.Column="2" Margin="5, 10, 10, 10">
<StackPanel Orientation="Vertical">
<ui:TextBlock Text="Ehavi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="monthTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<ui:ProgressRing x:Name="monthProgressRing" Grid.Column="0" Width="10" Height="10" VerticalAlignment="Center" HorizontalAlignment="Left" IsIndeterminate="True" Visibility="Hidden" />
<StackPanel Orientation="Vertical" Grid.Column="0">
<ui:TextBlock Text="Ehavi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="monthTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
<StackPanel Orientation="Vertical" Grid.Column="1">
<ui:TextBlock Text="Átlag per nap" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="averageTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
</Grid>
</ui:Card>
</Grid>
@@ -73,7 +94,7 @@
</Grid.ColumnDefinitions>
<ui:SymbolIcon Symbol="Search24" Grid.Row="0" Margin="30, 10, 10, 10" />
<ui:TextBox Grid.Row="0" Grid.Column="1" Margin="10" x:Name="searchTextBox" PlaceholderText="Keresés..." TextChanged="SearchTextBoxTextChanged" />
<ui:TextBox Grid.Row="0" Grid.Column="1" Margin="10" x:Name="searchTextBox" PlaceholderText="Keresés..." TextChanged="SearchTextBoxTextChanged" KeyUp="searchTextBox_KeyUp" />
<ui:Card Grid.Row="1" Grid.ColumnSpan="2" Margin="10, 10, 10, 10"
VerticalAlignment="Stretch" HorizontalAlignment="Stretch" VerticalContentAlignment="Stretch" HorizontalContentAlignment="Stretch">
@@ -85,6 +106,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="x"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto" SharedSizeGroup="y"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
@@ -92,28 +114,25 @@
<RowDefinition Height="2*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Column="0" Grid.Row="0" Grid.RowSpan="2" VerticalAlignment="Center" FontSize="20" FontFamily="/Resources/Inter.ttf#Inter"
Margin="5, 0, 10, 0" Text="{Binding IssueNumber}" />
Margin="5, 0, 10, 0" Text="{Binding Id}" />
<TextBlock Grid.Column="1" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" FontSize="14"
Text="{Binding IssueName}" TextTrimming="CharacterEllipsis" />
Text="{Binding Subject}" TextTrimming="CharacterEllipsis" ToolTip="{Binding Subject}" ToolTipService.InitialShowDelay="500" />
<TextBlock Grid.Column="1" Grid.Row="1" Grid.RowSpan="1" VerticalAlignment="Center" FontSize="10" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}"
Text="{Binding ProjectName}" />
<TextBlock Grid.Column="2" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="14"
Margin="20, 0, 10, 0" Text="{Binding SpentTime, StringFormat=N2}" />
<TextBlock Grid.Column="2" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="12"
Margin="20, 0, 10, 0" Text="{Binding StatusName}" />
<TextBlock Grid.Column="2" Grid.Row="1" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="10"
Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" Margin="20, 0, 10, 0" Text="{Binding LastUpdate}" />
Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" Margin="20, 0, 0, 0" Text="{Binding LastUpdate}"
ToolTip="{Binding UpdatedOn}" ToolTipService.InitialShowDelay="200" />
<ui:Button x:Name="openTicketButton" Grid.RowSpan="2" Grid.Column="3" Margin="10, 0, 10, 0" Click="openTicketButton_Click">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Open24" />
</ui:Button.Icon>
</ui:Button>
</Grid>
</DataTemplate>
</ui:ListView.ItemTemplate>
</ui:ListView>
<!--<ui:DataGrid Margin="0, 0, 0, 0" x:Name="issuesDataGrid" AutoGenerateColumns="False" IsReadOnly="True" SelectionChanged="DataGridSelectionChanged" >
<DataGrid.Columns>
<DataGridTextColumn Header="Jegy" Binding="{Binding IssueNumber}" Width="Auto"/>
<DataGridTextColumn Header="Projekt" Binding="{Binding ProjectName}" MaxWidth="150"/>
<DataGridTextColumn Header="Név" Binding="{Binding IssueName}" Width="1*"/>
<DataGridTextColumn Header="Idő" Binding="{Binding SpentTime}" Width="Auto"/>
</DataGrid.Columns>
</ui:DataGrid>-->
</ui:Card>
<ProgressBar x:Name="progressBar" Grid.Row="2" Grid.ColumnSpan="2" Height="10" Margin="10" VerticalAlignment="Bottom" Minimum="0" Maximum="100" Value="0"/>
</Grid>
@@ -156,13 +175,14 @@
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="1*"/>
<RowDefinition Height="1*"/>
</Grid.RowDefinitions>
<ui:Button x:Name="closeButton" Grid.Row="0" Grid.Column="0" Margin="0, 0, 5, 5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Lezár" Click="CloseButtonClick" >
VerticalAlignment="Stretch" Content="Státusz..." Click="CloseButtonClick" >
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Checkmark24"/>
</ui:Button.Icon>
@@ -173,14 +193,14 @@
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Globe24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="newButton" Grid.Row="0" Grid.Column="2" Margin="5, 0, 0, 5" HorizontalAlignment="Stretch"
<ui:Button x:Name="newButton" Grid.Row="0" Grid.Column="2" Margin="5, 0, 5, 5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Új jegy" >
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="New24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="apiButton" Grid.Row="1" Grid.Column="0" Margin="0, 5, 5, 0" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="API kulcs" Click="ApiButtonClicked">
VerticalAlignment="Stretch" Content="API kulcs..." Click="ApiButtonClicked">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Key24"/>
</ui:Button.Icon>
@@ -191,12 +211,36 @@
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="ArrowRotateClockwise24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="fixButton" Grid.Row="1" Grid.Column="2" Margin="5, 5, 0, 0" HorizontalAlignment="Stretch"
<ui:Button x:Name="fixButton" Grid.Row="1" Grid.Column="2" Margin="5, 5, 5, 0" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Fix" Click="FixButtonClick">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Wrench24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="trackerButton" Grid.Row="0" Grid.Column="3" Margin="5, 0, 0, 5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Tracker" Click="trackerButton_Click">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Timer24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="hoursButton" Grid.Row="1" Grid.Column="3" Margin="5, 5, 0, 0" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Órák" Click="hoursButton_Click">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="DocumentBulletList24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Flyout x:Name="statusFlyout" Grid.Row="0" Grid.Column="0">
<StackPanel Orientation="Vertical" Margin="10" Width="250">
<Label Content="Jegy státusz:" Margin="10" HorizontalAlignment="Stretch"/>
<ComboBox x:Name="statusComboBox" Margin="10" ItemsSource="{Binding StatusList}" DisplayMemberPath="Name" HorizontalAlignment="Stretch" />
<ui:Button x:Name="statusSaveButton" Margin="10" Content="Save" HorizontalAlignment="Stretch" Click="statusSaveButton_Click">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Save24" />
</ui:Button.Icon>
</ui:Button>
</StackPanel>
</ui:Flyout>
<ui:Flyout x:Name="apiFlyout" Grid.Row="1" Grid.Column="0">
<StackPanel Orientation="Vertical" Margin="10" Width="250">
@@ -210,6 +254,7 @@
</ui:Flyout>
</Grid>
<ui:TextBlock x:Name="statusTextBlock" Grid.Row="4" Grid.ColumnSpan="6" FontSize="8" Text="Staus: OK" Margin="10" />
<ui:ProgressRing x:Name="progressRing" Grid.Row="4" Height="10" Width="10" Margin="10" HorizontalAlignment="Left" IsIndeterminate="True" />
<ui:TextBlock x:Name="statusTextBlock" Grid.Row="4" Grid.ColumnSpan="6" FontSize="8" Text="Staus: OK" Margin="30, 10, 10, 10" />
</Grid>
</ui:FluentWindow>

View File

@@ -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<IssueItem> _issues = [];
public ObservableCollection<IssueItem> IssuesList { get; set; } = [];
private List<IssueList.Issue> _issues = [];
public ObservableCollection<IssueList.Issue> IssuesList { get; set; } = [];
public ObservableCollection<StatusList.IssueStatus> 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<bool> 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();
}
}
}

View File

@@ -1,29 +0,0 @@
namespace BlueMine.Redmine
{
public class AsyncLock
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<IDisposable> 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();
}
}
}
}

View File

@@ -1,10 +0,0 @@
namespace BlueMine.Redmine
{
public interface IRedmineCache<T>
{
void RefreshCache(List<T> newItems);
void InvalidateCache();
bool IsCacheValid();
List<T> GetItems();
}
}

View File

@@ -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<int> CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null);
Task<IEnumerable<SimpleProject>> GetProjectsAsync(int limit = 25, IProgress<(int, int)>? progress = null);
Task<List<SimpleTracker>> GetTrackersAsync(string projectId, CancellationToken? token = null);
Task VerifyApiKey();
Task<IEnumerable<SimpleIssue>> GetMyIssuesAsync(int limit = 25, IProgress<(int, int)>? progress = null);
Task<double> GetIssueTotalTimeAsync(int issueId);
Task<double> GetTodaysHoursAsync(DateTime startDate, DateTime endDate);
Task<List<IssueItem>> GetSpentTimeForIssuesAsync(List<SimpleIssue> simpleIssues, IProgress<(int, int)>? progress = null);
}
}

View File

@@ -1,16 +0,0 @@
using static BlueMine.Redmine.RedmineDto;
namespace BlueMine.Redmine
{
public interface IRedmineManager
{
Task<bool> IsRedmineAvailable();
Task LogTimeAsync(int issueId, double hours, string comments, DateTime date, int? activityId = null);
Task CloseIssueAsync(int issueId);
Task<int> CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null);
Task<List<SimpleProject>> GetProjectsAsync(int limit = 100, IProgress<(int, int)>? progress = null);
Task<List<SimpleTracker>> GetTrackersAsync(string projectId, CancellationToken? token = null);
Task<List<IssueItem>> GetCurrentIssuesAsync(IProgress<(int, int)>? progress = null);
Task<double> GetLoggedHoursAsync(DateTime? startDate = null, DateTime? endDate = null);
}
}

View File

@@ -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<RedmineAuthHandler> _logger;
public RedmineAuthHandler(RedmineConfig config, ILogger<RedmineAuthHandler> logger)
{
_logger = logger;
_config = config;
}
protected override async Task<HttpResponseMessage> 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);
}
}
}

View File

@@ -1,64 +0,0 @@
using Microsoft.Extensions.Logging;
namespace BlueMine.Redmine
{
class RedmineCache<T> : IRedmineCache<T>
{
private List<T> 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<RedmineCache<T>> logger)
{
_logger = logger;
_cacheDuration = cacheDuration;
}
public RedmineCache(int cacheDurationSec, ILogger<RedmineCache<T>> logger)
{
_logger = logger;
_cacheDuration = new TimeSpan(0, 0, cacheDurationSec);
}
public void RefreshCache(List<T> 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<T> GetItems()
{
lock (_lock)
{
_logger.LogDebug($"Returning {Items.Count} cached items");
return Items;
}
}
}
}

View File

@@ -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<RedmineConnect> _logger;
private int _userId = -1;
public RedmineConnect(HttpClient client, ILogger<RedmineConnect> 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<TResponse?> SendRequestAsync<TResponse>(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<TResponse>(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<object>(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<object>(HttpMethod.Put, url, payload);
_logger.LogInformation("Closed {IssueId}", issueId);
_logger.LogDebug("Issue {IssueId} closed successfully", issueId);
}
public async Task<int> 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<string, object>
{
["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<CreateIssueResponse>(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<IEnumerable<SimpleProject>> GetProjectsAsync(int limit = 25, IProgress<(int, int)>? progress = null)
{
int offset = 0;
int totalCount = 0;
var projects = new ConcurrentBag<SimpleProject>();
while (true)
{
string url = $"projects.json?limit={limit}&offset={offset}";
var response = await SendRequestAsync<ProjectListResponse>(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<List<SimpleTracker>> GetTrackersAsync(string projectId, CancellationToken? token = null)
{
string url = $"projects/{projectId}.json?include=trackers";
var response = await SendRequestAsync<ProjectWithTrackersResponse>(HttpMethod.Get, url, token: token);
var trackers = response?.Project?.Trackers.Select(t => new SimpleTracker
{
Id = t.Id,
Name = t.Name
}).ToList() ?? new List<SimpleTracker>();
_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<IssueListResponse>(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<IEnumerable<SimpleIssue>> GetMyIssuesAsync(int limit = 25, IProgress<(int, int)>? progress = null)
{
var offset = 0;
int totalCount = 0;
var issues = new ConcurrentBag<SimpleIssue>();
while(true)
{
string url = $"issues.json?assigned_to_id=me&status_id=open&limit={limit}&offset={offset}";
var response = await SendRequestAsync<IssueListResponse>(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<double> GetIssueTotalTimeAsync(int issueId)
{
string url = $"time_entries.json?issue_id={issueId}&limit=100";
var response = await SendRequestAsync<TimeEntryListResponse>(HttpMethod.Get, url);
if (response?.TimeEntries != null)
{
return response.TimeEntries.Sum(t => t.Hours);
}
return 0.0;
}
public async Task<double> 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<TimeEntryListResponse>(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<List<IssueItem>> GetSpentTimeForIssuesAsync(List<SimpleIssue> 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<IssueItem>();
var tasks = new List<Task>();
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];
}
}
}

View File

@@ -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} ó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";
}
}
}
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<IssueDto> 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<TimeEntryDto> TimeEntries { get; set; }
}
public class TimeEntryDto
{
[JsonPropertyName("hours")]
public double Hours { get; set; }
}
public class ProjectListResponse
{
[JsonPropertyName("projects")]
public List<ProjectDto> 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<TrackerDto> 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.

View File

@@ -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<SimpleProject> _projectCache;
private readonly RedmineCache<SimpleIssue> _issueCache;
private readonly ILogger<RedmineManager> _logger;
public RedmineManager(HttpClient httpClient, ILoggerFactory loggerFactory, RedmineConfig config)
{
ArgumentNullException.ThrowIfNull(httpClient);
ArgumentNullException.ThrowIfNull(loggerFactory);
ArgumentNullException.ThrowIfNull(config);
_logger = loggerFactory.CreateLogger<RedmineManager>();
_logger.LogDebug("Initializing RedmineManager with URL: {Url}", config.RedmineUrl);
_redmineConnect = new RedmineConnect(httpClient, loggerFactory.CreateLogger<RedmineConnect>(), config);
_projectCache = new(config.ProjectCacheDuration, loggerFactory.CreateLogger<RedmineCache<SimpleProject>>());
_issueCache = new(config.IssueCacheDuration, loggerFactory.CreateLogger<RedmineCache<SimpleIssue>>());
_logger.LogDebug("RedmineManager initialized");
}
/// <summary>
/// Checks if the Redmine instance is available by verifying the API key.
/// </summary>
/// <returns>True if available, false otherwise.</returns>
public async Task<bool> 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;
}
}
/// <summary>
/// Logs time for a specific issue.
/// </summary>
/// <param name="issueId">The issue ID.</param>
/// <param name="hours">Hours to log.</param>
/// <param name="comments">Comments for the time entry.</param>
/// <param name="date">Date of the time entry.</param>
/// <param name="activityId">Optional activity ID.</param>
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");
}
/// <summary>
/// Closes the specified issue.
/// </summary>
/// <param name="issueId">The issue ID to close.</param>
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);
}
/// <summary>
/// Creates a new issue in the specified project.
/// </summary>
/// <param name="projectId">The project ID.</param>
/// <param name="trackerId">The tracker ID.</param>
/// <param name="subject">Issue subject.</param>
/// <param name="description">Optional description.</param>
/// <param name="estimatedHours">Optional estimated hours.</param>
/// <param name="priorityId">Optional priority ID.</param>
/// <param name="parentIssueId">Optional parent issue ID.</param>
/// <returns>The created issue ID.</returns>
public async Task<int> 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;
}
}
/// <summary>
/// Retrieves the list of projects, using cache if valid.
/// </summary>
/// <param name="limit">Maximum number of projects to fetch per request.</param>
/// <param name="progress">Optional progress reporter.</param>
/// <returns>List of simple projects.</returns>
public async Task<List<SimpleProject>> GetProjectsAsync(int limit = 100, IProgress<(int, int)>? progress = null)
{
_logger.LogDebug("Getting projects");
using (await _lock.LockAsync())
{
List<SimpleProject> 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;
}
}
/// <summary>
/// Retrieves trackers for the specified project.
/// </summary>
/// <param name="projectId">The project ID.</param>
/// <param name="token">Optional cancellation token.</param>
/// <returns>List of simple trackers.</returns>
public async Task<List<SimpleTracker>> 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;
}
}
/// <summary>
/// Retrieves current issues with spent time.
/// </summary>
/// <param name="progress">Optional progress reporter.</param>
/// <returns>List of issue items.</returns>
public async Task<List<IssueItem>> GetCurrentIssuesAsync(IProgress<(int, int)>? progress = null)
{
_logger.LogDebug("Getting current issues");
using (await _lock.LockAsync())
{
List<SimpleIssue> 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;
}
}
/// <summary>
/// Retrieves logged hours for the specified date range.
/// </summary>
/// <param name="startDate">Start date.</param>
/// <param name="endDate">End date.</param>
/// <returns>Total logged hours.</returns>
public async Task<double> 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;
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,65 @@
<ui:FluentWindow x:Class="Blueberry.TimeTrackerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:local="clr-namespace:Blueberry"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance local:TimeTrackerWindow}"
Width="172" Height="38"
MinWidth="50" MinHeight="20"
MaxWidth="200" MaxHeight="150"
MouseLeftButtonDown="FluentWindow_MouseLeftButtonDown"
Title="Tracker"
Topmost="True"
Loaded="FluentWindow_Loaded"
WindowStartupLocation="CenterScreen">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="3*" />
</Grid.RowDefinitions>
<TextBlock x:Name="issueTextBlock" Grid.Row="0" Margin="10, 5, 10, 0" Text="#61612" VerticalAlignment="Center" HorizontalAlignment="Center"
FontSize="10" FontWeight="Bold" FontStyle="Italic" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}"
ToolTip="{Binding CurrentIssue}" ToolTipService.InitialShowDelay="100" />
<TextBlock x:Name="timeTextBlock" Grid.Row="1" Margin="10, 0, 10, 5" Text="00:00:00" VerticalAlignment="Center" />
</Grid>
<ui:Flyout x:Name="resultFlyout">
<Grid MinWidth="200">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Óra:" VerticalAlignment="Center" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Üzenet:" VerticalAlignment="Center" />
<ui:TextBox Grid.Row="0" Grid.Column="1" x:Name="resultHourTextBlock" Margin="8" IsReadOnly="True" />
<ui:TextBox Grid.Row="1" Grid.Column="1" x:Name="resultTextBox" Margin="8" PlaceholderText="Konzultáció..." />
<ui:Button x:Name="resultSaveButton" Grid.Row="2" Grid.ColumnSpan="2" HorizontalAlignment="Stretch" Margin="8" Content="Mentés" Click="resultSaveButton_Click"/>
</Grid>
</ui:Flyout>
<ui:Button x:Name="playPauseButton" Grid.Column="1" Margin="6, 0, 3, 0" Padding="4" Click="playPauseButton_Click">
<ui:SymbolIcon x:Name="playPauseIcon" Filled="True" Symbol="Play24" Foreground="{ui:ThemeResource AccentTextFillColorPrimaryBrush}" />
</ui:Button>
<ui:Button x:Name="doneButton" Grid.Column="2" Margin="3, 0, 3, 0" Padding="4" Click="doneButton_Click">
<ui:SymbolIcon Symbol="Checkmark24" />
</ui:Button>
<ui:Button x:Name="cancelButton" Grid.Column="3" Margin="3, 0, 6, 0" Padding="4" Click="cancelButton_Click">
<ui:SymbolIcon Symbol="DismissCircle24" />
</ui:Button>
</Grid>
</ui:FluentWindow>

View File

@@ -0,0 +1,108 @@
using Blueberry.Redmine;
using Blueberry.Redmine.Dto;
using System.Timers;
using System.Windows.Input;
using Wpf.Ui.Controls;
namespace Blueberry
{
/// <summary>
/// Interaction logic for TimeTrackerWindow.xaml
/// </summary>
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();
}
}
}
}

120
Blueberry/UpdateManager.cs Normal file
View File

@@ -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<Root>(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<Asset> assets { get; set; }
}
public class Asset
{
public string name { get; set; }
public string browser_download_url { get; set; }
}
}
}

View File

@@ -1,30 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>bb.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<InvariantGlobalization>true</InvariantGlobalization>
<DebuggerSupport>false</DebuggerSupport>
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
</PropertyGroup>
<ItemGroup>
<Content Include="bb.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -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<MemoryStream> 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; }
}
}
}

View File

@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC Manifest Options
If you want to change the Windows User Account Control level replace the
requestedExecutionLevel node with one of the following.
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
Specifying requestedExecutionLevel element will disable file and registry virtualization.
Remove this element if your application requires this virtualization for backwards
compatibility.
-->
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows Vista -->
<!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
<!-- Windows 7 -->
<!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
<!-- Windows 8 -->
<!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
<!-- Windows 8.1 -->
<!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
<!-- Windows 10 -->
<!--<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />-->
</application>
</compatibility>
<!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher
DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need
to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should
also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config.
Makes the application long-path aware. See https://docs.microsoft.com/windows/win32/fileio/maximum-file-path-limitation -->
<!--
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
-->
<!-- Enable themes for Windows common controls and dialogs (Windows XP and later) -->
<!--
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
-->
</assembly>

192
README.md Normal file
View File

@@ -0,0 +1,192 @@
# Blueberry
A modern WPF desktop application for Redmine issue management and time tracking. Built with .NET 8 and WPF-UI for a sleek, fluent design experience.
![Blueberry](/images/bb.ico)
## Features
### Core Functionality
- **Issue Management**: View, search, and manage your assigned Redmine issues
- **Time Tracking**: Log work hours with detailed comments and date selection
- **Real-time Metrics**: Track today's, yesterday's, and monthly hours with daily averages
- **Status Management**: Quick status updates for issues without leaving the app
- **Multi-language Support**: Hungarian interface with easy localization
### Technical Features
- **Offline Caching**: Intelligent caching system for improved performance
- **Auto-updater**: Built-in updater for seamless application updates
- **Modern UI**: Fluent Design with WPF-UI components
- **Responsive Layout**: Adaptive interface that works on different screen sizes
## Screenshots
The main interface provides:
- **Left Panel**: Searchable issue list with project and status information
- **Right Panel**: Time entry form with calendar integration and metrics dashboard
- **Quick Actions**: Status updates, browser integration, and API configuration
## Installation
### Automatic Installation (Recommended)
1. Download the latest `BlueberryUpdater.exe` from the [Releases](https://git.technopunk.space/tomi/Blueberry/releases) page
2. Run the updater - it will automatically download and install the latest version
3. The application will be installed to `C:\Program Files\Blueberry`
### Manual Installation
1. Download the latest `payload.zip` from the [Releases](https://git.technopunk.space/tomi/Blueberry/releases) page
2. Extract to your desired location
3. Run `Blueberry.exe`
## Configuration
### First-time Setup
1. Launch Blueberry
2. Click the "API kulcs..." button in the bottom-left corner
3. Enter your Redmine URL (e.g., `https://redmine.example.com`)
4. Enter your API key (get this from your Redmine account settings)
5. Click "Csatlakozás" to connect
### API Key Location
In your Redmine instance:
1. Go to **My Account****API access key**
2. Copy your API key
3. Use the "API kulcs link" button in Blueberry to go directly there
## Usage
### Time Tracking
1. Select one or more dates using the calendar button
2. Enter the issue number (e.g., `65432`)
3. Enter hours worked (e.g., `0.25` for 15 minutes)
4. Add a descriptive comment
5. Click "Send" to log your time
### Issue Management
- **Search**: Use the search box to filter issues by title or ID
- **Open in Browser**: Click the open button next to any issue to view it in your browser
- **Status Updates**: Select an issue and use "Státusz..." to change its status
- **Create New**: Use "Új jegy" to create new issues (requires project and tracker selection)
### Metrics Dashboard
The top-right cards show:
- **Mai órák**: Hours logged today
- **Tegnapi órák**: Hours logged yesterday
- **Ehavi órák**: Total hours this month
- **Átlag per nap**: Daily average for the current month
## Architecture
### Project Structure
```
Blueberry/
├── Blueberry/ # Main WPF application
│ ├── MainWindow.xaml # Primary interface
│ ├── IssueWindow.xaml # Issue detail view
│ └── Resources/ # Custom fonts (Inter, Roboto, Zalando)
├── Blueberry.Redmine/ # Redmine API integration
│ ├── Dto/ # Data transfer objects
│ ├── RedmineApiClient.cs # HTTP client for Redmine API
│ ├── RedmineManager.cs # Business logic layer
│ └── RedmineCache.cs # Caching system
└── BlueberryUpdater/ # Auto-updater utility
└── Program.cs # Installation/update logic
```
### Technologies Used
- **.NET 8** - Latest .NET framework with modern features
- **WPF** - Windows Presentation Foundation for desktop UI
- **WPF-UI** - Modern Fluent Design components
- **Microsoft.Extensions** - Logging, HTTP, and hosting
- **System.Text.Json** - JSON serialization for API responses
## Development
### Prerequisites
- Visual Studio 2022 (17.14+)
- .NET 8.0 SDK
- Windows 10/11 (targeting Windows 10 17763+)
### Building
```bash
git clone https://git.technopunk.space/tomi/Blueberry.git
cd Blueberry
dotnet build Blueberry.sln
```
### Running
```bash
dotnet run --project Blueberry/Blueberry.csproj
```
## Configuration Files
The application stores configuration in:
- **AppData**: `%APPDATA%\Blueberry\` for user settings and cache
- **Installation**: `C:\Program Files\Blueberry\` for application files
### Cache Files
- `Statuses.json` - Cached issue statuses
- `Priorities.json` - Cached priority levels
- `CustomFields.json` - Cached custom field definitions
- `Projects.json` - Cached project list
## API Endpoints Used
Blueberry integrates with Redmine's REST API:
- `/issues.json` - Issue listing and creation
- `/time_entries.json` - Time tracking
- `/users/current.json` - Current user info
- `/issue_statuses.json` - Available statuses
- `/projects.json` - Project listing
- `/custom_fields.json` - Custom field definitions
## Troubleshooting
### Connection Issues
- Verify your Redmine URL is accessible
- Check that your API key is valid and has proper permissions
- Ensure your Redmine instance has the REST API enabled
### Performance Issues
- Cache is automatically refreshed every hour
- Use the "Frissítés" button to manually refresh data
- Large issue lists may take time to load initially
### Updater Issues
- Run BlueberryUpdater.exe as administrator if installation fails
- Check your internet connection for downloading updates
- Temporarily disable antivirus if it blocks the installation
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Support
For issues and feature requests:
- Create an issue in the [repository](https://git.technopunk.space/tomi/Blueberry/issues)
- Check existing issues for known problems
- Include your Redmine version and Blueberry version in bug reports
## Changelog
### Latest Version
- Improved caching performance
- Enhanced UI responsiveness
- Bug fixes for time entry validation
### Previous Versions
See the [Releases](https://git.technopunk.space/tomi/Blueberry/releases) page for detailed version history.
---
**Blueberry** - Making Redmine time tracking and issue management effortless on Windows.

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

21
installer.ps1 Normal file
View File

@@ -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