Compare commits
18 Commits
5cb7895e24
...
0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ec077240a4 | |||
| 56fd7a6da7 | |||
| db063e61b1 | |||
| a86ad23774 | |||
| b68c43af38 | |||
| d15e01f253 | |||
| dc22000c79 | |||
| bd31fb6eb0 | |||
| ac787d1976 | |||
| d484b55823 | |||
| 1822fd24a1 | |||
| 136ceb1a9b | |||
| 7811b36edf | |||
| f9cbb69939 | |||
| 4eadf368f2 | |||
| 76cfb440b9 | |||
| ad6ca741e8 | |||
| c6097ab6dc |
105
Blueberry.Redmine/Dto/TimeOnIssue.cs
Normal file
105
Blueberry.Redmine/Dto/TimeOnIssue.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
#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 TimeOnIssue
|
||||
{
|
||||
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.
|
||||
@@ -5,18 +5,6 @@ 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")]
|
||||
@@ -39,6 +27,7 @@ namespace Blueberry.Redmine.Dto
|
||||
|
||||
[JsonPropertyName("lastname")]
|
||||
public string Lastname { get; set; }
|
||||
public string FullName => Lastname + " " + Firstname;
|
||||
|
||||
[JsonPropertyName("mail")]
|
||||
public string Mail { get; set; }
|
||||
@@ -50,22 +39,7 @@ namespace Blueberry.Redmine.Dto
|
||||
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; }
|
||||
public DateTime? LastLoginOn { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
24
Blueberry.Redmine/Dto/UserList.cs
Normal file
24
Blueberry.Redmine/Dto/UserList.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
#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 UserList
|
||||
{
|
||||
public class Root : IResponseList
|
||||
{
|
||||
[JsonPropertyName("users")]
|
||||
public List<UserInfo.User> Users { 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.
|
||||
@@ -1,10 +1,8 @@
|
||||
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
|
||||
{
|
||||
@@ -88,10 +86,14 @@ namespace Blueberry.Redmine
|
||||
|
||||
while (true)
|
||||
{
|
||||
var path = $"{endpoint}&limit={limit}&offset={offset}";
|
||||
var path = "";
|
||||
if(endpoint.Contains('?'))
|
||||
path = $"{endpoint}&limit={limit}&offset={offset}";
|
||||
else
|
||||
path = $"{endpoint}?limit={limit}&offset={offset}";
|
||||
|
||||
var responseList = await SendRequestAsync<TResponse>(HttpMethod.Get, path, token: token)
|
||||
?? throw new NullReferenceException();
|
||||
?? throw new NullReferenceException();
|
||||
|
||||
returnList.AddRange(itemParser(responseList));
|
||||
|
||||
@@ -139,7 +141,7 @@ namespace Blueberry.Redmine
|
||||
return fields.IssuePriorities;
|
||||
}
|
||||
|
||||
public async Task<List<IssueList.Issue>> GetOpenIssuesByAssignee(int userId, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken ? token = null)
|
||||
public async Task<List<IssueList.Issue>> GetOpenIssuesByAssigneeAsync(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";
|
||||
|
||||
@@ -149,7 +151,138 @@ namespace Blueberry.Redmine
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<List<ProjectList.Project>> GetProjects(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||
public async Task<List<IssueList.Issue>> GetIssuesAsync(
|
||||
int? userId = null,
|
||||
string? projectId = null,
|
||||
int? statusId = null,
|
||||
bool? isOpen = null,
|
||||
DateTime? createdOn = null,
|
||||
DateTime? updatedOn = null,
|
||||
int limit = PAGING_LIMIT,
|
||||
IProgress<(int, int)>? progress = null,
|
||||
CancellationToken? token = null)
|
||||
{
|
||||
// Start with the base endpoint
|
||||
// We use a List to build parameters cleanly to avoid formatting errors
|
||||
var queryParams = new List<string>();
|
||||
|
||||
// 1. Handle User ID
|
||||
if (userId != null)
|
||||
queryParams.Add($"assigned_to_id={userId}");
|
||||
|
||||
// 2. Handle Project ID
|
||||
if (!string.IsNullOrEmpty(projectId))
|
||||
queryParams.Add($"project_id={projectId}");
|
||||
|
||||
// 3. Handle Status (Prioritize explicit ID, then isOpen flag, then default to open)
|
||||
if (statusId != null)
|
||||
{
|
||||
queryParams.Add($"status_id={statusId}");
|
||||
}
|
||||
else if (isOpen != null)
|
||||
{
|
||||
queryParams.Add($"status_id={(isOpen.Value ? "open" : "closed")}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default behavior if neither is specified (preserves your original logic)
|
||||
queryParams.Add("status_id=open");
|
||||
}
|
||||
|
||||
// 4. Handle Dates (Using >= operator for "Since")
|
||||
if (createdOn != null)
|
||||
{
|
||||
// %3E%3D is ">="
|
||||
queryParams.Add($"created_on=%3E%3D{createdOn.Value:yyyy-MM-ddTHH:mm:ssZ}");
|
||||
}
|
||||
|
||||
if (updatedOn != null)
|
||||
{
|
||||
queryParams.Add($"updated_on=%3E%3D{updatedOn.Value:yyyy-MM-ddTHH:mm:ssZ}");
|
||||
}
|
||||
|
||||
// Join the path with the query string
|
||||
var queryString = string.Join("&", queryParams);
|
||||
var path = $"issues.json?{queryString}";
|
||||
|
||||
return await SendRequestWithPagingAsync<IssueList.Root, IssueList.Issue>(
|
||||
HttpMethod.Get, path, limit, (x) => x.Issues, progress, token: token);
|
||||
}
|
||||
|
||||
public async Task<List<IssueList.Issue>> GetIssuesAsync(
|
||||
int? userId = null,
|
||||
string? projectId = null,
|
||||
int? statusId = null,
|
||||
bool? isOpen = null,
|
||||
// Changed single dates to From/To pairs
|
||||
DateTime? createdFrom = null,
|
||||
DateTime? createdTo = null,
|
||||
DateTime? updatedFrom = null,
|
||||
DateTime? updatedTo = null,
|
||||
int limit = PAGING_LIMIT,
|
||||
IProgress<(int, int)>? progress = null,
|
||||
CancellationToken? token = null)
|
||||
{
|
||||
var queryParams = new List<string>();
|
||||
|
||||
// 1. Basic Filters
|
||||
if (userId != null) queryParams.Add($"assigned_to_id={userId}");
|
||||
if (!string.IsNullOrEmpty(projectId)) queryParams.Add($"project_id={projectId}");
|
||||
|
||||
// 2. Status Logic
|
||||
if (statusId != null)
|
||||
{
|
||||
queryParams.Add($"status_id={statusId}");
|
||||
}
|
||||
else if (isOpen != null)
|
||||
{
|
||||
queryParams.Add($"status_id={(isOpen.Value ? "open" : "closed")}");
|
||||
}
|
||||
else
|
||||
{
|
||||
queryParams.Add("status_id=open");
|
||||
}
|
||||
|
||||
// 3. Date Filter Logic (Helper function used below)
|
||||
string? createdFilter = BuildDateFilter("created_on", createdFrom, createdTo);
|
||||
if (createdFilter != null) queryParams.Add(createdFilter);
|
||||
|
||||
string? updatedFilter = BuildDateFilter("updated_on", updatedFrom, updatedTo);
|
||||
if (updatedFilter != null) queryParams.Add(updatedFilter);
|
||||
|
||||
// 4. Construct URL
|
||||
var queryString = string.Join("&", queryParams);
|
||||
var path = $"issues.json?{queryString}";
|
||||
|
||||
return await SendRequestWithPagingAsync<IssueList.Root, IssueList.Issue>(
|
||||
HttpMethod.Get, path, limit, (x) => x.Issues, progress, token: token);
|
||||
}
|
||||
|
||||
// Helper method to determine the correct Redmine operator
|
||||
private string? BuildDateFilter(string paramName, DateTime? from, DateTime? to)
|
||||
{
|
||||
string format = "yyyy-MM-ddTHH:mm:ssZ"; // ISO 8601
|
||||
|
||||
if (from.HasValue && to.HasValue)
|
||||
{
|
||||
// Range: "><START|END" (URL encoded as %3E%3C)
|
||||
return $"{paramName}=%3E%3C{from.Value.ToString(format)}|{to.Value.ToString(format)}";
|
||||
}
|
||||
else if (from.HasValue)
|
||||
{
|
||||
// After: ">=DATE" (URL encoded as %3E%3D)
|
||||
return $"{paramName}=%3E%3D{from.Value.ToString(format)}";
|
||||
}
|
||||
else if (to.HasValue)
|
||||
{
|
||||
// Before: "<=DATE" (URL encoded as %3C%3D)
|
||||
return $"{paramName}=%3C%3D{to.Value.ToString(format)}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<List<ProjectList.Project>> GetProjectsAsync(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||
{
|
||||
var path = $"projects.json";
|
||||
|
||||
@@ -159,7 +292,7 @@ namespace Blueberry.Redmine
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task<List<ProjectTrackers.Tracker>> GetTrackersForProject(string projectId, CancellationToken? token = null)
|
||||
public async Task<List<ProjectTrackers.Tracker>> GetTrackersForProjectAsync(string projectId, CancellationToken? token = null)
|
||||
{
|
||||
var path = $"projects/{projectId}.json?include=trackers";
|
||||
|
||||
@@ -169,7 +302,7 @@ namespace Blueberry.Redmine
|
||||
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)
|
||||
public async Task<double> GetTotalTimeForUserAsync(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");
|
||||
@@ -177,14 +310,24 @@ namespace Blueberry.Redmine
|
||||
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 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<List<UserTime.TimeEntry>> GetTimeForUserAsync(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");
|
||||
|
||||
public async Task<DetailedIssue.Issue> GetIssue(int issueId, CancellationToken? token = null)
|
||||
var path = $"time_entries.json?from={sText}&to={eText}&user_id={userId}";
|
||||
|
||||
|
||||
return await SendRequestWithPagingAsync<UserTime.Root, UserTime.TimeEntry>(HttpMethod.Get, path, limit, (x) => x.TimeEntries, progress, token: token);
|
||||
}
|
||||
|
||||
public async Task<DetailedIssue.Issue> GetIssueAsync(int issueId, CancellationToken? token = null)
|
||||
{
|
||||
var path = $"issues/{issueId}.json?include=journals";
|
||||
|
||||
@@ -194,7 +337,7 @@ namespace Blueberry.Redmine
|
||||
return issue.Issue;
|
||||
}
|
||||
|
||||
public async Task<IssueList.Issue> GetSimpleIssue(int issueId, CancellationToken? token = null)
|
||||
public async Task<IssueList.Issue> GetSimpleIssueAsync(int issueId, CancellationToken? token = null)
|
||||
{
|
||||
var path = $"issues/{issueId}.json?include=journals";
|
||||
|
||||
@@ -217,7 +360,14 @@ namespace Blueberry.Redmine
|
||||
return user.User;
|
||||
}
|
||||
|
||||
public async Task SetIssueStatus(int issueId, int statusId, CancellationToken? token = null)
|
||||
public async Task<List<UserInfo.User>> GetUsersAsync(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||
{
|
||||
var path = "users.json";
|
||||
|
||||
return await SendRequestWithPagingAsync<UserList.Root, UserInfo.User>(HttpMethod.Get, path, limit, (x) => x.Users, progress, token);
|
||||
}
|
||||
|
||||
public async Task SetIssueStatusAsync(int issueId, int statusId, CancellationToken? token = null)
|
||||
{
|
||||
var path = $"issues/{issueId}.json";
|
||||
|
||||
@@ -232,7 +382,30 @@ namespace Blueberry.Redmine
|
||||
await SendRequestAsync<object>(HttpMethod.Put, path, payload, token: token);
|
||||
}
|
||||
|
||||
public async Task<int> CreateNewIssue(int projectId, int trackerId, string subject, string description,
|
||||
public async Task AddCommentToIssueAsync(int issueId, string comment, bool isPrivate, CancellationToken? token = null)
|
||||
{
|
||||
var path = $"issues/{issueId}.json";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
issue = new
|
||||
{
|
||||
notes = comment,
|
||||
private_notes = isPrivate
|
||||
}
|
||||
};
|
||||
|
||||
await SendRequestAsync<object>(HttpMethod.Put, path, payload, token: token);
|
||||
}
|
||||
|
||||
public async Task<List<TimeOnIssue.TimeEntry>> GetTimeOnIssueAsync(int issueId, int limit = 25, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||
{
|
||||
var path = $"time_entries.json?issue_id={issueId}";
|
||||
var times = await SendRequestWithPagingAsync<TimeOnIssue.Root, TimeOnIssue.TimeEntry>(HttpMethod.Get, path, limit, (x)=>x.TimeEntries, progress, token: token);
|
||||
return times;
|
||||
}
|
||||
|
||||
public async Task<int> CreateNewIssueAsync(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";
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Blueberry.Redmine
|
||||
{
|
||||
public class RedmineManager
|
||||
{
|
||||
private readonly TimeSpan DEFAULT_CACHE_DURATION = TimeSpan.FromHours(1);
|
||||
private readonly TimeSpan DEFAULT_CACHE_DURATION = TimeSpan.FromHours(3);
|
||||
private readonly RedmineConfig _config;
|
||||
private readonly ILogger _logger;
|
||||
private readonly RedmineApiClient _apiClient;
|
||||
@@ -13,6 +13,7 @@ namespace Blueberry.Redmine
|
||||
private readonly RedmineCache<PriorityList.IssuePriority> _priorityCache;
|
||||
private readonly RedmineCache<CustomFieldList.CustomField> _customFieldCache;
|
||||
private readonly RedmineCache<ProjectList.Project> _projectCache;
|
||||
private readonly RedmineCache<UserInfo.User> _userCache;
|
||||
|
||||
public RedmineManager(RedmineConfig config, HttpClient client, ILoggerFactory loggerFactory)
|
||||
{
|
||||
@@ -31,6 +32,9 @@ namespace Blueberry.Redmine
|
||||
_projectCache = new RedmineCache<ProjectList.Project>(
|
||||
DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger<RedmineCache<ProjectList.Project>>(), cacheFilePath: $"{_config.CacheFilePath}Projects.json");
|
||||
|
||||
_userCache = new RedmineCache<UserInfo.User>(
|
||||
DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger<RedmineCache<UserInfo.User>>(), cacheFilePath: $"{_config.CacheFilePath}Users.json");
|
||||
|
||||
_logger = loggerFactory.CreateLogger<RedmineManager>();
|
||||
_logger.LogDebug("Initialized caches");
|
||||
}
|
||||
@@ -39,7 +43,7 @@ namespace Blueberry.Redmine
|
||||
{
|
||||
try
|
||||
{
|
||||
await _apiClient.GetUserAsync();
|
||||
await _apiClient.GetUserAsync(token: token);
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -87,11 +91,22 @@ namespace Blueberry.Redmine
|
||||
{
|
||||
return await _projectCache.GetItemsAsync();
|
||||
}
|
||||
var projects = await _apiClient.GetProjects(limit, progress, token);
|
||||
var projects = await _apiClient.GetProjectsAsync(limit, progress, token);
|
||||
await _projectCache.RefreshCacheAsync(projects);
|
||||
return projects;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserInfo.User>> GetUsersAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||
{
|
||||
if (_userCache.IsCacheValid())
|
||||
{
|
||||
return await _userCache.GetItemsAsync();
|
||||
}
|
||||
var users = await _apiClient.GetUsersAsync(limit, progress, token);
|
||||
await _userCache.RefreshCacheAsync(users);
|
||||
return users;
|
||||
}
|
||||
|
||||
public async Task<UserInfo.User> GetCurrentUserAsync(CancellationToken? token = null)
|
||||
{
|
||||
var user = await _apiClient.GetUserAsync(token: token);
|
||||
@@ -104,36 +119,74 @@ namespace Blueberry.Redmine
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task<List<IssueList.Issue>> GetCurrentUserIssuesAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||
public async Task<List<IssueList.Issue>> GetCurrentUserOpenIssuesAsync(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);
|
||||
return await _apiClient.GetOpenIssuesByAssigneeAsync(user.Id, limit, progress, token);
|
||||
}
|
||||
|
||||
public async Task<List<IssueList.Issue>> GetUserOpenIssuesAsync(int userId, int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||
{
|
||||
return await _apiClient.GetOpenIssuesByAssigneeAsync(userId, limit, progress, token);
|
||||
}
|
||||
|
||||
public async Task<List<IssueList.Issue>> GetIssuesAsync(
|
||||
int? userId = null,
|
||||
string? projectId = null,
|
||||
int? statusId = null,
|
||||
bool? isOpen = null,
|
||||
// Changed single dates to From/To pairs
|
||||
DateTime? createdFrom = null,
|
||||
DateTime? createdTo = null,
|
||||
DateTime? updatedFrom = null,
|
||||
DateTime? updatedTo = null,
|
||||
int limit = 50,
|
||||
IProgress<(int, int)>? progress = null,
|
||||
CancellationToken? token = null)
|
||||
{
|
||||
return await _apiClient.GetIssuesAsync(userId, projectId, statusId, isOpen, createdFrom, createdTo, updatedFrom, updatedTo, limit, progress, token);
|
||||
}
|
||||
|
||||
public async Task AddComment(int issueId, string comment, bool isPrivate = false, CancellationToken? token = null)
|
||||
{
|
||||
await _apiClient.AddCommentToIssueAsync(issueId, comment, isPrivate, 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);
|
||||
return await _apiClient.GetTotalTimeForUserAsync(user.Id, start, end, limit, progress, token);
|
||||
}
|
||||
|
||||
public async Task<double> GetUserTimeAsync(int userId, DateTime start, DateTime end, int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||
{
|
||||
return await _apiClient.GetTotalTimeForUserAsync(userId, start, end, limit, progress, token);
|
||||
}
|
||||
|
||||
public async Task<DetailedIssue.Issue> GetIssueAsync(int issueId, CancellationToken? token = null)
|
||||
{
|
||||
return await _apiClient.GetIssue(issueId, token);
|
||||
return await _apiClient.GetIssueAsync(issueId, token);
|
||||
}
|
||||
|
||||
public async Task<IssueList.Issue> GetSimpleIssueAsync(int issueId, CancellationToken? token = null)
|
||||
{
|
||||
return await _apiClient.GetSimpleIssue(issueId, token);
|
||||
return await _apiClient.GetSimpleIssueAsync(issueId, token);
|
||||
}
|
||||
|
||||
public async Task<List<ProjectTrackers.Tracker>> GetProjectTrackersAsync(int projectId, CancellationToken? token = null)
|
||||
{
|
||||
return await _apiClient.GetTrackersForProject(projectId.ToString(), token);
|
||||
return await _apiClient.GetTrackersForProjectAsync(projectId.ToString(), token);
|
||||
}
|
||||
|
||||
public async Task<List<TimeOnIssue.TimeEntry>> GetTimeOnIssue(int issueId, int limit = 25, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||
{
|
||||
var result = await _apiClient.GetTimeOnIssueAsync(issueId, limit, progress, token);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task SetIssueStatusAsync(int issueId, int statusId, CancellationToken? token = null)
|
||||
{
|
||||
await _apiClient.SetIssueStatus(issueId, statusId, token);
|
||||
await _apiClient.SetIssueStatusAsync(issueId, statusId, token);
|
||||
}
|
||||
|
||||
public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null, CancellationToken? token = null)
|
||||
@@ -144,7 +197,7 @@ namespace Blueberry.Redmine
|
||||
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);
|
||||
return await _apiClient.CreateNewIssueAsync(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)
|
||||
@@ -163,5 +216,10 @@ namespace Blueberry.Redmine
|
||||
var end = start.AddMonths(1).AddDays(-1);
|
||||
return await GetCurrentUserTimeAsync(start, end, limit, progress, token);
|
||||
}
|
||||
|
||||
public async Task<List<UserTime.TimeEntry>> GetTimeForUserAsync(int userId, DateTime start, DateTime end, int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||
{
|
||||
return await _apiClient.GetTimeForUserAsync(userId, start, end, limit, progress, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,10 +53,6 @@ namespace BlueMine
|
||||
/// </summary>
|
||||
private async void OnStartup(object sender, StartupEventArgs e)
|
||||
{
|
||||
#if !DEBUG
|
||||
var update = new UpdateManager();
|
||||
await update.CheckAndInstallAsync();
|
||||
#endif
|
||||
await _host.StartAsync();
|
||||
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
|
||||
mainWindow.Show();
|
||||
@@ -70,6 +66,11 @@ namespace BlueMine
|
||||
await _host.StopAsync();
|
||||
|
||||
_host.Dispose();
|
||||
|
||||
if (await UpdateManager.IsUpdateAvailable())
|
||||
{
|
||||
await UpdateManager.WaitUntilDownloadCompleteAndUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -28,5 +28,73 @@
|
||||
"Nem elszámolható hívások, email, chat",
|
||||
"Nem elszámolható telefon, chat, email kommunikáció",
|
||||
];
|
||||
|
||||
public static readonly string UpdateScriptRestart = @"# 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";
|
||||
|
||||
public static readonly string UpdateScriptNoRestart = @"# 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
|
||||
|
||||
# SELF-DESTRUCT: Remove this script
|
||||
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force";
|
||||
}
|
||||
}
|
||||
|
||||
133
Blueberry/HoursWindow.xaml
Normal file
133
Blueberry/HoursWindow.xaml
Normal file
@@ -0,0 +1,133 @@
|
||||
<ui:FluentWindow x:Class="Blueberry.HoursWindow"
|
||||
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:HoursWindow}"
|
||||
Loaded="FluentWindow_Loaded"
|
||||
Title="HoursWindow" Height="550" Width="800" MinWidth="670" MinHeight="450">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="1*" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="1*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ui:TitleBar Title="Órák" Grid.ColumnSpan="2">
|
||||
<ui:TitleBar.Icon>
|
||||
<ui:ImageIcon Source="/bb.ico" />
|
||||
</ui:TitleBar.Icon>
|
||||
</ui:TitleBar>
|
||||
<Grid x:Name="userSelectionGrid" Grid.Row="1" Grid.ColumnSpan="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="1*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ui:TextBlock Text="Felhasználó" VerticalAlignment="Center" Margin="10" />
|
||||
<ComboBox x:Name="userComboBox" Grid.Column="1" Margin="10, 10, 5, 10" IsEditable="True" DisplayMemberPath="FullName" />
|
||||
<ui:Button Grid.Column="2" Content="Dátum" Margin="5, 10" Padding="10" x:Name="dateButton" Click="dateButton_Click">
|
||||
<ui:Button.Icon>
|
||||
<ui:SymbolIcon Symbol="Timeline24" />
|
||||
</ui:Button.Icon>
|
||||
</ui:Button>
|
||||
<ui:Button Grid.Column="3" Content="Keresés" Margin="5, 10, 10, 10" Padding="10" x:Name="searchButton" Click="searchButton_Click">
|
||||
<ui:Button.Icon>
|
||||
<ui:SymbolIcon Symbol="Search24" />
|
||||
</ui:Button.Icon>
|
||||
</ui:Button>
|
||||
<ui:Flyout Grid.Column="2" x:Name="calendarFlyout">
|
||||
<StackPanel Orientation="Vertical">
|
||||
<Calendar x:Name="userCalendar" SelectionMode="SingleRange" IsTodayHighlighted="True" />
|
||||
</StackPanel>
|
||||
</ui:Flyout>
|
||||
</Grid>
|
||||
<ui:ProgressRing x:Name="hoursProgress" Grid.Row="2" Height="50" Width="50" IsIndeterminate="True" />
|
||||
<ui:Card Grid.Row="2" Margin="10, 10, 0, 10" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch">
|
||||
<ui:ListView ItemsSource="{Binding Hours}" Grid.IsSharedSizeScope="True" ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
VirtualizingPanel.ScrollUnit="Pixel">
|
||||
<ui:ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" SharedSizeGroup="x" />
|
||||
<ColumnDefinition Width="1*" />
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="Auto" SharedSizeGroup="y" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ui:Card Visibility="{Binding CardVisibility}" Padding="50, 2, 50, 2" Margin="50, 2, 50, 2" Grid.ColumnSpan="4" HorizontalAlignment="Center">
|
||||
<ui:TextBlock Text="{Binding SeparatorText}" FontStyle="Italic" FontWeight="Bold" Margin="0, 6, 0, 0"
|
||||
FontSize="10" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</ui:Card>
|
||||
<ui:Button Grid.Column="0" Margin="5" Content="{Binding IssueId}" x:Name="openIssueButton" Click="openIssueButton_Click" Visibility="{Binding ButtonVisibility}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||
<Grid Grid.Column="1">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="2*" />
|
||||
<RowDefinition Height="1*" />
|
||||
</Grid.RowDefinitions>
|
||||
<ui:TextBlock Grid.Row="0" Margin="5, 2, 5, 0" Text="{Binding IssueName}" TextTrimming="CharacterEllipsis" />
|
||||
<ui:TextBlock Grid.Row="1" Margin="5, 0, 5, 2" Text="{Binding ProjectName}" FontSize="10" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
<ui:TextBlock Grid.Column="2" Margin="5" Text="{Binding Comments}" ToolTip="{Binding Comments}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<ui:TextBlock Grid.Column="3" Margin="5, 5, 10, 5" Text="{Binding Hours}" FontSize="18" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ui:ListView.ItemTemplate>
|
||||
</ui:ListView>
|
||||
</ui:Card>
|
||||
|
||||
<ui:Card Grid.Column="1" Grid.Row="2" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" Margin="10">
|
||||
<Grid x:Name="dataGrid" VerticalAlignment="Stretch">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="1*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="1*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<ui:TextBlock Grid.Row="3" Margin="0,150" />
|
||||
|
||||
<ui:TextBlock Grid.Column="0" Grid.Row="0" Margin="5" Text="Összes óra:" />
|
||||
<ui:TextBlock Grid.Column="0" Grid.Row="1" Margin="5" Text="Átlag (per nap):" />
|
||||
<ui:TextBlock Grid.Column="0" Grid.Row="2" Margin="5" Text="Átlag (per munkanap):" />
|
||||
<ui:TextBlock Grid.Column="0" Grid.Row="4" Margin="5" Text="Új jegyek:" />
|
||||
<ui:TextBlock Grid.Column="0" Grid.Row="5" Margin="5" Text="Lezárt jegyek:" />
|
||||
<ui:TextBlock Grid.Column="0" Grid.Row="6" Margin="5" Text="Min. jegy életkor:" />
|
||||
<ui:TextBlock Grid.Column="0" Grid.Row="7" Margin="5" Text="Max. jegy életkor:" />
|
||||
<ui:TextBlock Grid.Column="0" Grid.Row="8" Margin="5" Text="Átlag jegy életkor:" />
|
||||
<ui:TextBlock Grid.Column="0" Grid.Row="9" Margin="5" Text="Medián jegy életkor:" />
|
||||
|
||||
<ui:TextBlock Grid.Column="1" Grid.Row="0" Margin="5" Text="0.0" x:Name="totalHoursTextBlock" />
|
||||
<ui:TextBlock Grid.Column="1" Grid.Row="1" Margin="5" Text="0.0" x:Name="avgDayTextBlock" />
|
||||
<ui:TextBlock Grid.Column="1" Grid.Row="2" Margin="5" Text="0.0" x:Name="avgWorkdayTextBlock"/>
|
||||
<ui:TextBlock Grid.Column="1" Grid.Row="4" Margin="5" Text="0.0" x:Name="newTicketsTextBlock" />
|
||||
<ui:TextBlock Grid.Column="1" Grid.Row="5" Margin="5" Text="0.0" x:Name="closedTicketsTextBlock" />
|
||||
<ui:TextBlock Grid.Column="1" Grid.Row="6" Margin="5" Text="0.0" x:Name="minTicketAgeTextBlock" />
|
||||
<ui:TextBlock Grid.Column="1" Grid.Row="7" Margin="5" Text="0.0" x:Name="maxTicketAgeTextBlock" />
|
||||
<ui:TextBlock Grid.Column="1" Grid.Row="8" Margin="5" Text="0.0" x:Name="avgTicketAgeTextBlock" />
|
||||
<ui:TextBlock Grid.Column="1" Grid.Row="9" Margin="5" Text="0.0" x:Name="medianTicketAgeTextBlock" />
|
||||
</Grid>
|
||||
</ui:Card>
|
||||
</Grid>
|
||||
</ui:FluentWindow>
|
||||
251
Blueberry/HoursWindow.xaml.cs
Normal file
251
Blueberry/HoursWindow.xaml.cs
Normal file
@@ -0,0 +1,251 @@
|
||||
using Blueberry.Redmine;
|
||||
using Blueberry.Redmine.Dto;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using Wpf.Ui.Controls;
|
||||
|
||||
namespace Blueberry
|
||||
{
|
||||
/// <summary>
|
||||
/// Interaction logic for HoursWindow.xaml
|
||||
/// </summary>
|
||||
public partial class HoursWindow : FluentWindow
|
||||
{
|
||||
private readonly List<UserInfo.User> _users = [];
|
||||
private readonly RedmineManager _manager;
|
||||
private readonly RedmineConfig _config;
|
||||
private readonly ConcurrentDictionary<int, string> _issueNames = [];
|
||||
|
||||
public ObservableCollection<DisplayHours> Hours { get; set; } = [];
|
||||
|
||||
public HoursWindow(RedmineManager manager, RedmineConfig config)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = this;
|
||||
_manager = manager;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
private async void FluentWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
userComboBox.IsEnabled =
|
||||
searchButton.IsEnabled =
|
||||
dateButton.IsEnabled = false;
|
||||
var u = await _manager.GetUsersAsync(progress: UpdateProgress());
|
||||
var current = await _manager.GetCurrentUserAsync();
|
||||
hoursProgress.Visibility = Visibility.Hidden;
|
||||
hoursProgress.IsIndeterminate = true;
|
||||
_users.Clear();
|
||||
_users.AddRange(u);
|
||||
userComboBox.Items.Clear();
|
||||
foreach (var user in u)
|
||||
userComboBox.Items.Add(user);
|
||||
|
||||
userComboBox.SelectedItem = current;
|
||||
userComboBox.IsEnabled =
|
||||
searchButton.IsEnabled =
|
||||
dateButton.IsEnabled = true;
|
||||
}
|
||||
|
||||
private IProgress<(int, int)> UpdateProgress()
|
||||
{
|
||||
var p = new Progress<(int current, int total)>((x) =>
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
hoursProgress.IsIndeterminate = false;
|
||||
var percent = (int)((double)x.current / x.total * 100);
|
||||
hoursProgress.Progress = percent;
|
||||
});
|
||||
});
|
||||
return p;
|
||||
}
|
||||
|
||||
private void dateButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
calendarFlyout.IsOpen = true;
|
||||
}
|
||||
|
||||
private void UpdateProgressI()
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
var percent = (int)((double)loadI / totalI * 100);
|
||||
lock (_lock)
|
||||
{
|
||||
hoursProgress.Progress = percent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int loadInternalI = 0;
|
||||
private object _lock = new();
|
||||
private int loadI { get
|
||||
{
|
||||
var result = 0;
|
||||
lock(_lock)
|
||||
{
|
||||
result = loadInternalI;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
loadInternalI = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
private int totalI = 0;
|
||||
private async void searchButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var user = userComboBox.SelectedItem as UserInfo.User;
|
||||
if(user is null)
|
||||
return;
|
||||
|
||||
var selectedDates = userCalendar.SelectedDates;
|
||||
if (selectedDates.Count == 0)
|
||||
return;
|
||||
|
||||
ConcurrentBag<DisplayHours> hours = [];
|
||||
Task[] tasks = new Task[selectedDates.Count];
|
||||
Hours.Clear();
|
||||
hoursProgress.Visibility= Visibility.Visible;
|
||||
|
||||
for (int i = 0; i < selectedDates.Count; i++)
|
||||
{
|
||||
hoursProgress.IsIndeterminate = false;
|
||||
totalI = selectedDates.Count;
|
||||
var date = selectedDates[i];
|
||||
tasks[i] = Task.Run(async () =>
|
||||
{
|
||||
var h = await _manager.GetTimeForUserAsync(user.Id, date, date);
|
||||
foreach (var item in h)
|
||||
{
|
||||
var dh = new DisplayHours()
|
||||
{
|
||||
ProjectName = item.Project.Name,
|
||||
IssueName = await GetIssueNameAsync(item.Issue.Id),
|
||||
IssueId = item.Issue.Id.ToString(),
|
||||
Date = date,
|
||||
CreatedOn = item.CreatedOn,
|
||||
Hours = item.Hours.ToString(),
|
||||
Comments = item.Comments,
|
||||
IsSeparator = false
|
||||
};
|
||||
hours.Add(dh);
|
||||
}
|
||||
loadI++;
|
||||
UpdateProgressI();
|
||||
});
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
hoursProgress.IsIndeterminate = true;
|
||||
|
||||
var newTickets = await _manager.GetIssuesAsync(user.Id, createdFrom: selectedDates.First(), createdTo: selectedDates.Last());
|
||||
var closedTickets = await _manager.GetIssuesAsync(user.Id, isOpen: false, updatedFrom: selectedDates.First(), updatedTo: selectedDates.Last());
|
||||
var currentTickets = await _manager.GetUserOpenIssuesAsync(user.Id);
|
||||
|
||||
var total = hours.Sum(x => double.Parse(x.Hours));
|
||||
totalHoursTextBlock.Text = total.ToString();
|
||||
avgDayTextBlock.Text = Math.Round(total / selectedDates.Count, 2).ToString();
|
||||
|
||||
int workingDays = 0;
|
||||
foreach (var day in selectedDates)
|
||||
{
|
||||
if(day.DayOfWeek != DayOfWeek.Saturday &&
|
||||
day.DayOfWeek != DayOfWeek.Sunday)
|
||||
workingDays++;
|
||||
}
|
||||
avgWorkdayTextBlock.Text = Math.Round(total / workingDays, 2).ToString();
|
||||
|
||||
newTicketsTextBlock.Text = newTickets.Count.ToString();
|
||||
closedTicketsTextBlock.Text = closedTickets.Count.ToString();
|
||||
|
||||
minTicketAgeTextBlock.Text = Math.Round(currentTickets.Min(x => (DateTime.Now - x.CreatedOn).TotalDays), 2) + " nap";
|
||||
maxTicketAgeTextBlock.Text = Math.Round(currentTickets.Max(x => (DateTime.Now - x.CreatedOn).TotalDays), 2) + " nap";
|
||||
avgTicketAgeTextBlock.Text = Math.Round(currentTickets.Average(x => (DateTime.Now - x.CreatedOn).TotalDays), 2) + " nap";
|
||||
var ages = currentTickets.Select(x => (DateTime.Now - x.CreatedOn).TotalDays).Order().ToList();
|
||||
medianTicketAgeTextBlock.Text = Math.Round(ages[ages.Count / 2], 2) + " nap";
|
||||
|
||||
var orderedHours = hours.OrderByDescending(h => h.CreatedOn).OrderBy(h => h.Date).ToList();
|
||||
var previousDate = DateTime.MinValue;
|
||||
for (int i = 0; i < orderedHours.Count; i++)
|
||||
{
|
||||
if(orderedHours[i].Date.Date > previousDate)
|
||||
{
|
||||
total = orderedHours.Where(x => x.Date.Date == orderedHours[i].Date.Date).Sum(x => double.Parse(x.Hours));
|
||||
var dh = new DisplayHours()
|
||||
{
|
||||
IsSeparator = true,
|
||||
SeparatorText = orderedHours[i].Date.ToString("yyyy-MM-dd") + " | Összesen: " + total + " óra"
|
||||
};
|
||||
orderedHours.Insert(i, dh);
|
||||
previousDate = orderedHours[i+1].Date.Date;
|
||||
}
|
||||
}
|
||||
|
||||
hoursProgress.Visibility = Visibility.Hidden;
|
||||
|
||||
foreach (var item in orderedHours)
|
||||
Hours.Add(item);
|
||||
}
|
||||
|
||||
private async Task<string> GetIssueNameAsync(int issueId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_issueNames.ContainsKey(issueId))
|
||||
return _issueNames[issueId];
|
||||
|
||||
var name = (await _manager.GetSimpleIssueAsync(issueId)).Subject;
|
||||
_issueNames.TryAdd(issueId, name);
|
||||
return name;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.Message);
|
||||
Console.WriteLine(issueId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public class DisplayHours
|
||||
{
|
||||
public string ProjectName { get; set; } = "";
|
||||
public string IssueName { get; set; } = "";
|
||||
public string IssueId { get; set; } = "";
|
||||
public DateTime Date { get; set; }
|
||||
public DateTime CreatedOn { get; set; }
|
||||
public string Hours { get; set; } = "";
|
||||
public string Comments { get; set; } = "";
|
||||
public string SeparatorText { get; set; } = "";
|
||||
public bool IsSeparator { get; set; }
|
||||
public Visibility CardVisibility => IsSeparator ? Visibility.Visible : Visibility.Hidden;
|
||||
public Visibility ButtonVisibility => IsSeparator ? Visibility.Hidden : Visibility.Visible;
|
||||
}
|
||||
|
||||
private async void openIssueButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is FrameworkElement button && button.DataContext is DisplayHours item)
|
||||
{
|
||||
var issueId = int.Parse(item.IssueId);
|
||||
|
||||
try
|
||||
{
|
||||
var issue = await _manager.GetIssueAsync(issueId);
|
||||
var issueWindow = new IssueWindow(issue, _manager, _config);
|
||||
issueWindow.Show();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance local:IssueWindow}"
|
||||
Loaded="FluentWindow_Loaded"
|
||||
Closing="FluentWindow_Closing"
|
||||
Title="IssueWindow" Height="500" Width="900">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
@@ -78,6 +79,7 @@
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="1*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="1*" />
|
||||
@@ -129,7 +131,14 @@
|
||||
<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" />
|
||||
<Grid Grid.Row="1" Grid.Column="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ui:TextBlock Grid.Column="0" Text="{Binding User}" FontSize="10" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" FontWeight="Bold" />
|
||||
<ui:SymbolIcon Symbol="LockClosed24" Grid.Column="1" Visibility="{Binding LockVisibility}" Margin="5, 0, 0, 0" FontSize="12" Foreground="Orange" VerticalAlignment="Top" />
|
||||
</Grid>
|
||||
<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>
|
||||
@@ -139,6 +148,23 @@
|
||||
</ui:ListView>
|
||||
</Grid>
|
||||
</ui:Card>
|
||||
<Grid Grid.Row="2" Grid.ColumnSpan="2">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="1*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ui:TextBox Grid.RowSpan="2" Margin="10, 10, 5, 10" x:Name="commentTextBox" AcceptsReturn="True" MinLines="2" />
|
||||
<ui:Button Grid.Column="1" Content="Küldés" Margin="5, 10, 10, 5" x:Name="commentButton" Click="commentButton_Click">
|
||||
<ui:Button.Icon>
|
||||
<ui:SymbolIcon Symbol="Send24" />
|
||||
</ui:Button.Icon>
|
||||
</ui:Button>
|
||||
<ui:ToggleSwitch x:Name="privateToggle" Grid.Row="1" Grid.Column="1" OnContent="Privát" OffContent="Privát" Margin="5, 5, 10, 10" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ui:FluentWindow>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
using Blueberry.Redmine.Dto;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using Windows.Networking.NetworkOperators;
|
||||
using Wpf.Ui.Controls;
|
||||
|
||||
namespace Blueberry
|
||||
@@ -13,10 +15,11 @@ namespace Blueberry
|
||||
/// </summary>
|
||||
public partial class IssueWindow : FluentWindow
|
||||
{
|
||||
private readonly DetailedIssue.Issue _issue;
|
||||
private DetailedIssue.Issue _issue;
|
||||
private readonly RedmineManager _manager;
|
||||
private readonly RedmineConfig _config;
|
||||
private readonly List<JournalDisplay> _journalDisplays = [];
|
||||
private CancellationTokenSource _tokenSource = new();
|
||||
public ObservableCollection<JournalDisplay> Journals { get; set; } = [];
|
||||
|
||||
public IssueWindow(DetailedIssue.Issue issue, RedmineManager manager, RedmineConfig config)
|
||||
@@ -36,14 +39,46 @@ namespace Blueberry
|
||||
iCreatedTextBox.Text = _issue.CreatedOn.ToString("yyyy-MM-dd");
|
||||
iUpdatedTextBox.Text = _issue.UpdatedOn.ToString("yyyy-MM-dd");
|
||||
iSpentTimeTextBox.Text = _issue.SpentHours.ToString();
|
||||
await DownloadJournals();
|
||||
}
|
||||
|
||||
private async Task DownloadJournals()
|
||||
{
|
||||
Journals.Clear();
|
||||
journalProgressRing.Visibility = Visibility.Visible;
|
||||
_journalDisplays.AddRange(await ProcessJournal(_issue.Journals));
|
||||
if(!_journalDisplays.Any(x=>!x.IsData))
|
||||
|
||||
List<TimeOnIssue.TimeEntry> hours = [];
|
||||
try
|
||||
{
|
||||
var id = _issue.Id;
|
||||
var newIssue = await _manager.GetIssueAsync(id);
|
||||
if(newIssue != null)
|
||||
_issue = newIssue;
|
||||
hours = await _manager.GetTimeOnIssue(_issue.Id, progress: UpdateProgress(), token: _tokenSource.Token);
|
||||
}
|
||||
catch { }
|
||||
_journalDisplays.Clear();
|
||||
_journalDisplays.AddRange(await ProcessJournal(_issue.Journals, hours));
|
||||
if (!_journalDisplays.Any(x => !x.IsData))
|
||||
detailsToggleSwitch.IsChecked = true;
|
||||
await LoadJournal();
|
||||
journalProgressRing.Visibility = Visibility.Hidden;
|
||||
}
|
||||
|
||||
private IProgress<(int, int)> UpdateProgress()
|
||||
{
|
||||
var p = new Progress<(int current, int total)>((x) =>
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
journalProgressRing.IsIndeterminate = false;
|
||||
var percent = (int)Math.Round((double)x.current / x.total * 100);
|
||||
journalProgressRing.Progress = percent;
|
||||
});
|
||||
});
|
||||
return p;
|
||||
}
|
||||
|
||||
private async Task LoadJournal()
|
||||
{
|
||||
var showDetails = detailsToggleSwitch.IsChecked ?? true;
|
||||
@@ -71,11 +106,29 @@ namespace Blueberry
|
||||
};
|
||||
Process.Start(psi);
|
||||
}
|
||||
|
||||
private void FluentWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
_tokenSource.Cancel();
|
||||
}
|
||||
|
||||
private async void commentButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var comment = commentTextBox.Text;
|
||||
if (string.IsNullOrWhiteSpace(comment))
|
||||
return;
|
||||
|
||||
var isPrivate = privateToggle.IsChecked ?? false;
|
||||
|
||||
await _manager.AddComment(_issue.Id, comment, isPrivate);
|
||||
commentTextBox.Text = "";
|
||||
await DownloadJournals();
|
||||
}
|
||||
}
|
||||
|
||||
public partial class IssueWindow
|
||||
{
|
||||
public async Task<List<JournalDisplay>> ProcessJournal(IEnumerable<DetailedIssue.Journal> journals)
|
||||
public async Task<List<JournalDisplay>> ProcessJournal(IEnumerable<DetailedIssue.Journal> journals, List<TimeOnIssue.TimeEntry> hours)
|
||||
{
|
||||
var js = new List<JournalDisplay>();
|
||||
|
||||
@@ -187,6 +240,24 @@ namespace Blueberry
|
||||
} catch (Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
var totalHours = 0d;
|
||||
hours = [.. hours.OrderBy(x => x.CreatedOn)];
|
||||
foreach (var hour in hours)
|
||||
{
|
||||
totalHours += hour.Hours;
|
||||
var user = hour.User.Name;
|
||||
var date = hour.CreatedOn.ToString("yyyy-MM-dd HH:mm");
|
||||
var content = $"Idő: {hour.Hours}\nEddig összesen: {totalHours}\n{hour.Comments}";
|
||||
js.Add(new JournalDisplay
|
||||
{
|
||||
User = user,
|
||||
Date = date,
|
||||
Content = content,
|
||||
IsData = true
|
||||
});
|
||||
}
|
||||
js = [.. js.OrderBy(x => x.Date)];
|
||||
return js;
|
||||
}
|
||||
|
||||
@@ -207,6 +278,7 @@ namespace Blueberry
|
||||
return Application.Current.TryFindResource(resourceKey) as Brush;
|
||||
}
|
||||
}
|
||||
public Visibility LockVisibility => IsPrivate ? Visibility.Visible : Visibility.Hidden;
|
||||
|
||||
public SolidColorBrush NameColor => StringToColorConverter.GetColorFromName(User);
|
||||
}
|
||||
|
||||
@@ -256,6 +256,14 @@
|
||||
|
||||
<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" />
|
||||
<ui:TextBlock x:Name="versionTextBlock" Grid.Row="4" Grid.Column="2" HorizontalAlignment="Right" FontSize="8" Text="0.0.0" Margin="10" />
|
||||
<Grid Grid.Row="4" Grid.Column="2" >
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="1*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ui:Button x:Name="updateButton" Content="Frissítés" FontSize="8" Grid.Column="1" Margin="2" Visibility="Hidden" Click="updateButton_Click" />
|
||||
<ui:TextBlock x:Name="versionTextBlock" Grid.Column="2" HorizontalAlignment="Right" FontSize="8" Text="0.0.0" Margin="10" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ui:FluentWindow>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -45,9 +44,35 @@ namespace BlueMine
|
||||
Task getHoursTask = GetHours();
|
||||
|
||||
await Task.WhenAll(loadIssuesTask, getHoursTask);
|
||||
#if !DEBUG
|
||||
if(await UpdateManager.IsUpdateAvailable())
|
||||
{
|
||||
updateButton.Visibility = Visibility.Visible;
|
||||
UpdateManager.DownloadCompleted += UpdateManager_DownloadCompleted;
|
||||
await UpdateManager.DownloadUpdateAsync();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private async void UpdateManager_DownloadCompleted()
|
||||
{
|
||||
await Dispatcher.Invoke(async () =>
|
||||
{
|
||||
var result = await new Wpf.Ui.Controls.MessageBox
|
||||
{
|
||||
Title = "Frissítés elérhető",
|
||||
Content = "Szeretnél most frissíteni?",
|
||||
PrimaryButtonText = "Frissítés",
|
||||
SecondaryButtonText = "Később",
|
||||
IsCloseButtonEnabled = false,
|
||||
}.ShowDialogAsync();
|
||||
|
||||
if (result == Wpf.Ui.Controls.MessageBoxResult.Primary)
|
||||
await UpdateManager.PerformUpdate(true);
|
||||
});
|
||||
}
|
||||
|
||||
private void CalendarButtonClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
flyoutCalendar.IsOpen = true;
|
||||
@@ -343,10 +368,13 @@ namespace BlueMine
|
||||
|
||||
private async void hoursButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await new Wpf.Ui.Controls.MessageBox
|
||||
{
|
||||
Title = "Under construction"
|
||||
}.ShowDialogAsync();
|
||||
var hoursWindow = new HoursWindow(_manager, _config);
|
||||
hoursWindow.Show();
|
||||
}
|
||||
|
||||
private void updateButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UpdateManager_DownloadCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,7 +485,7 @@ namespace BlueMine
|
||||
foreach (var issueId in Constants.StaticTickets)
|
||||
_issues.Add(await _manager.GetSimpleIssueAsync(issueId));
|
||||
|
||||
_issues.AddRange(await _manager.GetCurrentUserIssuesAsync(progress: UpdateProgress("Jegyek letöltése:")));
|
||||
_issues.AddRange(await _manager.GetCurrentUserOpenIssuesAsync(progress: UpdateProgress("Jegyek letöltése:")));
|
||||
progressBar.Value = 0;
|
||||
progressRing.Visibility = Visibility.Hidden;
|
||||
FilterIssues();
|
||||
@@ -498,7 +526,7 @@ namespace BlueMine
|
||||
statusTextBlock.Text = $"Kapcsolódás Redminehoz...";
|
||||
|
||||
int maxRetries = 3;
|
||||
int timeoutSeconds = 1; // Force kill after 5s
|
||||
int timeoutSeconds = 3; // Force kill after 5s
|
||||
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
{
|
||||
@@ -525,7 +553,7 @@ namespace BlueMine
|
||||
statusTextBlock.Text = $"Kapcsolódási hiba. Újrapróbálkozás: {i + 1}/{maxRetries}";
|
||||
|
||||
// Wait 1 second before retrying
|
||||
await Task.Delay(1000);
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
|
||||
// All attempts failed
|
||||
|
||||
@@ -5,30 +5,102 @@ using System.Text.Json;
|
||||
|
||||
namespace Blueberry
|
||||
{
|
||||
public class UpdateManager
|
||||
public static class UpdateManager
|
||||
{
|
||||
private const string releaseUrl = "https://git.technopunk.space/api/v1/repos/tomi/Blueberry/releases/latest";
|
||||
public const string CurrentVersion = "0.1.1";
|
||||
public const string CurrentVersion = "0.2.0";
|
||||
private static readonly string appDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
private static readonly string zipPath = Path.Combine(appDir, "blueberry_update.zip");
|
||||
private static readonly HttpClient client = new();
|
||||
public delegate void DownloadCompletedEventArgs();
|
||||
public static event DownloadCompletedEventArgs DownloadCompleted;
|
||||
private static bool isDownloading = false;
|
||||
|
||||
public async Task CheckAndInstallAsync()
|
||||
public static async Task<bool> IsUpdateAvailable()
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var json = await client.GetStringAsync(releaseUrl);
|
||||
var release = JsonSerializer.Deserialize<Root>(json);
|
||||
return release != null && release.tag_name != CurrentVersion;
|
||||
}
|
||||
|
||||
public static async Task WaitUntilDownloadCompleteAndUpdate()
|
||||
{
|
||||
var json = await client.GetStringAsync(releaseUrl);
|
||||
var release = JsonSerializer.Deserialize<Root>(json);
|
||||
var file = release.assets.Find(x => x.name.Contains(".zip"));
|
||||
if (!File.Exists(zipPath))
|
||||
await Download(client, file.browser_download_url, 0);
|
||||
|
||||
long localSize = new FileInfo(zipPath).Length;
|
||||
if (localSize != file.size)
|
||||
{
|
||||
if (!isDownloading)
|
||||
{
|
||||
await Download(client, file.browser_download_url, localSize);
|
||||
if (localSize != file.size)
|
||||
await PerformUpdate();
|
||||
}
|
||||
else
|
||||
{
|
||||
do
|
||||
{
|
||||
await Task.Delay(500);
|
||||
localSize = new FileInfo(zipPath).Length;
|
||||
} while (localSize != file.size);
|
||||
localSize = new FileInfo(zipPath).Length;
|
||||
if (localSize != file.size)
|
||||
await PerformUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
localSize = new FileInfo(zipPath).Length;
|
||||
if (localSize != file.size)
|
||||
return;
|
||||
else
|
||||
await PerformUpdate();
|
||||
}
|
||||
|
||||
public static async Task DownloadUpdateAsync()
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "Blueberry-Updater");
|
||||
|
||||
try
|
||||
{
|
||||
var json = client.GetStringAsync(releaseUrl).ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
// 1. Use await here, don't block
|
||||
var json = await client.GetStringAsync(releaseUrl);
|
||||
var release = JsonSerializer.Deserialize<Root>(json);
|
||||
|
||||
if (release == null)
|
||||
throw new NullReferenceException();
|
||||
if (release == null) return;
|
||||
|
||||
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);
|
||||
var file = release.assets.Find(x => x.name.Contains(".zip"));
|
||||
if (file == null) return;
|
||||
|
||||
long offset = 0;
|
||||
|
||||
if (File.Exists(zipPath))
|
||||
{
|
||||
long localSize = new FileInfo(zipPath).Length;
|
||||
|
||||
if (localSize == file.size)
|
||||
{
|
||||
DownloadCompleted?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
if (localSize > file.size)
|
||||
{
|
||||
File.Delete(zipPath);
|
||||
offset = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
offset = localSize;
|
||||
}
|
||||
}
|
||||
if (!isDownloading)
|
||||
await Download(client, file.browser_download_url, offset);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -37,55 +109,45 @@ namespace Blueberry
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PerformUpdate(HttpClient client, string url)
|
||||
private static async Task Download(HttpClient client, string url, long offset = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
isDownloading = true;
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// If we have an offset, ask the server for the rest of the file
|
||||
if (offset > 0)
|
||||
{
|
||||
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(offset, null);
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// If offset > 0, we APPEND. If 0, we CREATE/OVERWRITE.
|
||||
var fileMode = offset > 0 ? FileMode.Append : FileMode.Create;
|
||||
|
||||
using var contentStream = await response.Content.ReadAsStreamAsync();
|
||||
using var fileStream = new FileStream(zipPath, fileMode, FileAccess.Write, FileShare.None);
|
||||
|
||||
await contentStream.CopyToAsync(fileStream);
|
||||
isDownloading = false;
|
||||
DownloadCompleted?.Invoke();
|
||||
} catch (Exception)
|
||||
{
|
||||
isDownloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task PerformUpdate(bool restart = false)
|
||||
{
|
||||
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 psScript = (restart ? Constants.UpdateScriptRestart : Constants.UpdateScriptNoRestart)
|
||||
.Replace("{currentExe}", $"{currentExe}")
|
||||
.Replace("{tempZip}", $"{zipPath}")
|
||||
.Replace("{appDir}", $"{appDir}");
|
||||
string psPath = Path.Combine(Path.GetTempPath(), "blueberry_updater.ps1");
|
||||
File.WriteAllText(psPath, psScript);
|
||||
|
||||
@@ -94,9 +156,9 @@ Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{psPath}\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
UseShellExecute = true,
|
||||
CreateNoWindow = false,
|
||||
WindowStyle = ProcessWindowStyle.Normal
|
||||
};
|
||||
|
||||
Process.Start(startInfo);
|
||||
@@ -114,6 +176,7 @@ Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force
|
||||
public class Asset
|
||||
{
|
||||
public string name { get; set; }
|
||||
public long size { get; set; }
|
||||
public string browser_download_url { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
210
README.md
210
README.md
@@ -1,195 +1,55 @@
|
||||
# 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
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
Egy modern Redmine kliens asztali alkalmazás Windowsra, amely egyszerűsíti a projektmenedzsmentet és időkövetést.
|
||||
|
||||
### 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
|
||||
## Mit tud a rendszer?
|
||||
|
||||
### 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
|
||||
### Feladatok kezelése
|
||||
- **Mindent egy helyen**: Látod a saját jegyeidet a legfontosabb adatokkal (szám, projekt, státusz).
|
||||
- **Gyorskereső**: Ha tudod a jegyszámot, azonnal megtalálod.
|
||||
- **Státuszok**: Pár kattintással frissítheted, hogy épp hol tartasz a munkával.
|
||||
|
||||
## Screenshots
|
||||
### Időmérés
|
||||
- **Hogy állsz?**: Bármikor ránézhetsz a mai, tegnapi vagy havi óráidra és átlagodra.
|
||||
- **Rugalmas rögzítés**: Nem kell naponta egyesével adminisztrálni, egyszerre több napot is beírhatsz.
|
||||
- **Mindig naprakész**: Az adatok maguktól frissülnek, nem kell újratöltened az oldalt.
|
||||
|
||||
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
|
||||
## Telepítés
|
||||
|
||||
## Installation
|
||||
### Automatikus (ajánlott)
|
||||
|
||||
### Automatic Installation (Recommended)
|
||||
```ps1
|
||||
Nyiss egy PowerShell terminált és futtasd a következő parancsot:
|
||||
|
||||
```powershell
|
||||
iwr https://blueberry.technopunk.space | iex
|
||||
```
|
||||
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`
|
||||
### Kézi telepítés
|
||||
|
||||
## Configuration
|
||||
1. Töltsd le a legfrissebb `payload.zip` fájlt a [Releases](https://git.technopunk.space/tomi/Blueberry/releases) oldalról
|
||||
2. Csomagold ki a kívánt helyre
|
||||
3. Indítsd el a `Blueberry.exe` fájlt
|
||||
|
||||
### 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
|
||||
## Használat
|
||||
|
||||
### 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
|
||||
### Első alkalommal
|
||||
|
||||
## Usage
|
||||
1. Indítsd el az alkalmazást
|
||||
2. Kattints az **"API kulcs..."** gombra a képernyő alján középen
|
||||
3. Add meg a Redmine URL-t (pl. `https://redmine.example.com`)
|
||||
4. Add meg az API kulcsot (az URL beírása után az API kulcs gombra kattintva megnyílik a böngésző, ahol megtalálod)
|
||||
5. Kattints a **"Csatlakozás"** gombra és kész is vagy!
|
||||
|
||||
### 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
|
||||
### API kulcs beszerzése
|
||||
|
||||
### 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)
|
||||
Az API kulcsot a Redmine fiókod beállításaiban találod:
|
||||
- Jelentkezz be a Redmine rendszerbe
|
||||
- Navigálj a **Fiókom** → **API hozzáférési kulcs** menüpontra
|
||||
- Kattints a **Megjelenítés** gombra a kulcs megjelenítéséhez
|
||||
|
||||
### 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
|
||||
## Rendszerkövetelmények
|
||||
|
||||
## 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.
|
||||
- Windows 10 vagy újabb
|
||||
- Internetkapcsolat a Redmine szerverhez
|
||||
|
||||
Reference in New Issue
Block a user