Compare commits
32 Commits
1d3a678333
...
0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ec077240a4 | |||
| 56fd7a6da7 | |||
| db063e61b1 | |||
| a86ad23774 | |||
| b68c43af38 | |||
| d15e01f253 | |||
| dc22000c79 | |||
| bd31fb6eb0 | |||
| ac787d1976 | |||
| d484b55823 | |||
| 1822fd24a1 | |||
| 136ceb1a9b | |||
| 7811b36edf | |||
| f9cbb69939 | |||
| 4eadf368f2 | |||
| 76cfb440b9 | |||
| ad6ca741e8 | |||
| c6097ab6dc | |||
| 5cb7895e24 | |||
| 21acb733b6 | |||
| a946929c3e | |||
| 9254b4cf2f | |||
| fbf3b6826c | |||
| 41c7ec292c | |||
| 37984f0631 | |||
| 22ded9029c | |||
| e2fe68a29a | |||
| ba4e31334f | |||
| 31ff035b5e | |||
| fcaf8718c7 | |||
| 7bf15ce021 | |||
| b08df1568c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,13 +2,14 @@
|
|||||||
## files generated by popular Visual Studio add-ons.
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
|
build.installer.ps1
|
||||||
*.suo
|
*.suo
|
||||||
*.user
|
*.user
|
||||||
*.sln.docstates
|
*.sln.docstates
|
||||||
.vs/
|
.vs/
|
||||||
|
|
||||||
# Build results
|
# Build results
|
||||||
./BlueberryUpdater/*.zip
|
BlueberryUpdater/*.zip
|
||||||
FinalBuild/
|
FinalBuild/
|
||||||
[Dd]ebug/
|
[Dd]ebug/
|
||||||
[Rr]elease/
|
[Rr]elease/
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
using static BlueMine.Redmine.RedmineDto;
|
|
||||||
|
|
||||||
namespace BlueMine
|
|
||||||
{
|
|
||||||
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 string[] GenericMessages = [
|
|
||||||
"Config reszelés",
|
|
||||||
"Telefon, mail, chat",
|
|
||||||
"Meet, email és egyéb",
|
|
||||||
"Tanulás, dokumentálás",
|
|
||||||
"Adminisztrációs cuccok",
|
|
||||||
"Doksi készítés, tanulás",
|
|
||||||
"Doksi túrás, hibakeresés",
|
|
||||||
"Kollégákkal kommunikáció",
|
|
||||||
"Adminisztrációs feladatok",
|
|
||||||
"Napi admin körök, redmine",
|
|
||||||
"SAP dokumnetáció, önképzés",
|
|
||||||
"Általános admin és papírmunka",
|
|
||||||
"Belső egyeztetések, meetingek",
|
|
||||||
"Jegyezés, emailek, chat, stb.",
|
|
||||||
"SAP doksik olvasása, önképzés",
|
|
||||||
"Jegyek átnézése, adminisztráció",
|
|
||||||
"VPN szívás, emailek, chat, stb.",
|
|
||||||
"Saját gép karbantartása, updatek",
|
|
||||||
"Technikai utánaolvasás, research",
|
|
||||||
"SAP Note-ok böngészése, tesztelés",
|
|
||||||
"Nem elszámolható hívások, email, chat",
|
|
||||||
"Nem elszámolható telefon, chat, email kommunikáció",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace BlueMine.Redmine
|
|
||||||
{
|
|
||||||
public interface IRedmineCache<T>
|
|
||||||
{
|
|
||||||
void RefreshCache(List<T> newItems);
|
|
||||||
void InvalidateCache();
|
|
||||||
bool IsCacheValid();
|
|
||||||
List<T> GetItems();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
Blueberry.Redmine/Blueberry.Redmine.csproj
Normal file
14
Blueberry.Redmine/Blueberry.Redmine.csproj
Normal 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>
|
||||||
95
Blueberry.Redmine/Dto/CustomFieldList.cs
Normal file
95
Blueberry.Redmine/Dto/CustomFieldList.cs
Normal 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.
|
||||||
197
Blueberry.Redmine/Dto/DetailedIssue.cs
Normal file
197
Blueberry.Redmine/Dto/DetailedIssue.cs
Normal 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.
|
||||||
9
Blueberry.Redmine/Dto/IResponseList.cs
Normal file
9
Blueberry.Redmine/Dto/IResponseList.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
183
Blueberry.Redmine/Dto/IssueList.cs
Normal file
183
Blueberry.Redmine/Dto/IssueList.cs
Normal 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.
|
||||||
131
Blueberry.Redmine/Dto/NewIssue.cs
Normal file
131
Blueberry.Redmine/Dto/NewIssue.cs
Normal 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.
|
||||||
32
Blueberry.Redmine/Dto/PriorityList.cs
Normal file
32
Blueberry.Redmine/Dto/PriorityList.cs
Normal 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.
|
||||||
83
Blueberry.Redmine/Dto/ProjectList.cs
Normal file
83
Blueberry.Redmine/Dto/ProjectList.cs
Normal 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.
|
||||||
89
Blueberry.Redmine/Dto/ProjectTrackers.cs
Normal file
89
Blueberry.Redmine/Dto/ProjectTrackers.cs
Normal 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.
|
||||||
29
Blueberry.Redmine/Dto/StatusList.cs
Normal file
29
Blueberry.Redmine/Dto/StatusList.cs
Normal 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.
|
||||||
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.
|
||||||
48
Blueberry.Redmine/Dto/UserInfo.cs
Normal file
48
Blueberry.Redmine/Dto/UserInfo.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#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 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; }
|
||||||
|
public string FullName => Lastname + " " + Firstname;
|
||||||
|
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#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.
|
||||||
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.
|
||||||
107
Blueberry.Redmine/Dto/UserTime.cs
Normal file
107
Blueberry.Redmine/Dto/UserTime.cs
Normal 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.
|
||||||
455
Blueberry.Redmine/RedmineApiClient.cs
Normal file
455
Blueberry.Redmine/RedmineApiClient.cs
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
using Blueberry.Redmine.Dto;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
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 = "";
|
||||||
|
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();
|
||||||
|
|
||||||
|
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>> 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";
|
||||||
|
|
||||||
|
var items = await SendRequestWithPagingAsync<IssueList.Root, IssueList.Issue>(HttpMethod.Get, path, limit, (x) => x.Issues,
|
||||||
|
progress, token: token);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
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>> GetTrackersForProjectAsync(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> 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");
|
||||||
|
|
||||||
|
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<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");
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
var issue = await SendRequestAsync<DetailedIssue.Root>(HttpMethod.Get, path, token: token)
|
||||||
|
?? throw new NullReferenceException();
|
||||||
|
|
||||||
|
return issue.Issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IssueList.Issue> GetSimpleIssueAsync(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<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";
|
||||||
|
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
issue = new
|
||||||
|
{
|
||||||
|
status_id = statusId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await SendRequestAsync<object>(HttpMethod.Put, path, payload, token: token);
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace BlueMine.Redmine
|
namespace Blueberry.Redmine
|
||||||
{
|
{
|
||||||
public class RedmineApiException : Exception
|
public class RedmineApiException : Exception
|
||||||
{
|
{
|
||||||
208
Blueberry.Redmine/RedmineCache.cs
Normal file
208
Blueberry.Redmine/RedmineCache.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace BlueMine.Redmine
|
namespace Blueberry.Redmine
|
||||||
{
|
{
|
||||||
public class RedmineConfig
|
public class RedmineConfig
|
||||||
{
|
{
|
||||||
@@ -8,5 +8,7 @@ namespace BlueMine.Redmine
|
|||||||
public TimeSpan IssueCacheDuration { get; set; } = TimeSpan.FromMinutes(5);
|
public TimeSpan IssueCacheDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||||
public int MaxRetries { get; set; } = 3;
|
public int MaxRetries { get; set; } = 3;
|
||||||
public int ConcurrencyLimit { get; set; } = 10;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
225
Blueberry.Redmine/RedmineManager.cs
Normal file
225
Blueberry.Redmine/RedmineManager.cs
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
using Blueberry.Redmine.Dto;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Blueberry.Redmine
|
||||||
|
{
|
||||||
|
public class RedmineManager
|
||||||
|
{
|
||||||
|
private readonly TimeSpan DEFAULT_CACHE_DURATION = TimeSpan.FromHours(3);
|
||||||
|
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;
|
||||||
|
private readonly RedmineCache<UserInfo.User> _userCache;
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
_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");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsRedmineAvailable(CancellationToken? token = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _apiClient.GetUserAsync(token: token);
|
||||||
|
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.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);
|
||||||
|
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>> GetCurrentUserOpenIssuesAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null)
|
||||||
|
{
|
||||||
|
var user = await GetCurrentUserAsync(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.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.GetIssueAsync(issueId, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IssueList.Issue> GetSimpleIssueAsync(int issueId, CancellationToken? token = null)
|
||||||
|
{
|
||||||
|
return await _apiClient.GetSimpleIssueAsync(issueId, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ProjectTrackers.Tracker>> GetProjectTrackersAsync(int projectId, CancellationToken? token = null)
|
||||||
|
{
|
||||||
|
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.SetIssueStatusAsync(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.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)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using BlueMine.Redmine;
|
|
||||||
using System.IO;
|
|
||||||
using System.Security.Cryptography; // For encryption
|
using System.Security.Cryptography; // For encryption
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace BlueMine
|
namespace Blueberry.Redmine
|
||||||
{
|
{
|
||||||
|
public class RedmineSettingsManager
|
||||||
|
|
||||||
public class SettingsManager
|
|
||||||
{
|
{
|
||||||
// Save to: C:\Users\Username\AppData\Roaming\YourAppName\settings.json
|
|
||||||
private readonly string _filePath = Path.Combine(
|
private readonly string _filePath = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
"Blueberry",
|
"Blueberry",
|
||||||
@@ -19,7 +14,10 @@ namespace BlueMine
|
|||||||
public RedmineConfig Load()
|
public RedmineConfig Load()
|
||||||
{
|
{
|
||||||
if (!File.Exists(_filePath))
|
if (!File.Exists(_filePath))
|
||||||
return new RedmineConfig();
|
return new RedmineConfig()
|
||||||
|
{
|
||||||
|
IsInitiating = true,
|
||||||
|
};
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -28,7 +26,6 @@ namespace BlueMine
|
|||||||
if(config == null)
|
if(config == null)
|
||||||
return new RedmineConfig();
|
return new RedmineConfig();
|
||||||
|
|
||||||
// Decrypt the API Key if it exists
|
|
||||||
if (!string.IsNullOrEmpty(config.ApiKey))
|
if (!string.IsNullOrEmpty(config.ApiKey))
|
||||||
{
|
{
|
||||||
config.ApiKey = Unprotect(config.ApiKey);
|
config.ApiKey = Unprotect(config.ApiKey);
|
||||||
@@ -37,34 +34,30 @@ namespace BlueMine
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// If file is corrupted, return default
|
|
||||||
return new RedmineConfig();
|
return new RedmineConfig();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Save(RedmineConfig config)
|
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."));
|
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
|
var copy = new RedmineConfig
|
||||||
{
|
{
|
||||||
RedmineUrl = config.RedmineUrl,
|
RedmineUrl = config.RedmineUrl,
|
||||||
// Encrypt the key before saving
|
|
||||||
ApiKey = Protect(config.ApiKey),
|
ApiKey = Protect(config.ApiKey),
|
||||||
ProjectCacheDuration = config.ProjectCacheDuration,
|
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 });
|
var json = JsonSerializer.Serialize(copy, new JsonSerializerOptions { WriteIndented = true });
|
||||||
File.WriteAllText(_filePath, json);
|
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)
|
private string Protect(string clearText)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(clearText)) return "";
|
if (string.IsNullOrEmpty(clearText)) return "";
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 18
|
||||||
VisualStudioVersion = 17.14.36603.0
|
VisualStudioVersion = 18.1.11304.174 d18.0
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlueMine", "BlueMine\BlueMine.csproj", "{201018E0-4328-4B0A-8BD7-0E3AC6155A68}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blueberry", "Blueberry\Blueberry.csproj", "{201018E0-4328-4B0A-8BD7-0E3AC6155A68}"
|
||||||
EndProject
|
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
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Release|Any CPU.Build.0 = 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
|
{54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{54CFDEC5-3E7B-4B9F-B76C-B0E742729624}.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}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using BlueMine.Redmine;
|
using Blueberry;
|
||||||
|
using Blueberry.Redmine;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -23,11 +24,11 @@ namespace BlueMine
|
|||||||
.ConfigureAppConfiguration(c => { c.SetBasePath(Path.GetDirectoryName(AppContext.BaseDirectory) ?? throw new NullReferenceException()); })
|
.ConfigureAppConfiguration(c => { c.SetBasePath(Path.GetDirectoryName(AppContext.BaseDirectory) ?? throw new NullReferenceException()); })
|
||||||
.ConfigureServices((context, services) =>
|
.ConfigureServices((context, services) =>
|
||||||
{
|
{
|
||||||
services.AddSingleton<SettingsManager>();
|
services.AddSingleton<RedmineSettingsManager>();
|
||||||
|
|
||||||
services.AddSingleton(sp =>
|
services.AddSingleton(sp =>
|
||||||
{
|
{
|
||||||
var manager = sp.GetRequiredService<SettingsManager>();
|
var manager = sp.GetRequiredService<RedmineSettingsManager>();
|
||||||
return manager.Load();
|
return manager.Load();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,6 +66,11 @@ namespace BlueMine
|
|||||||
await _host.StopAsync();
|
await _host.StopAsync();
|
||||||
|
|
||||||
_host.Dispose();
|
_host.Dispose();
|
||||||
|
|
||||||
|
if (await UpdateManager.IsUpdateAvailable())
|
||||||
|
{
|
||||||
|
await UpdateManager.WaitUntilDownloadCompleteAndUpdate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -40,4 +40,8 @@
|
|||||||
</Content>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Blueberry.Redmine\Blueberry.Redmine.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
100
Blueberry/Constants.cs
Normal file
100
Blueberry/Constants.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
namespace Blueberry
|
||||||
|
{
|
||||||
|
internal class Constants
|
||||||
|
{
|
||||||
|
public static int[] StaticTickets = [705, 801];
|
||||||
|
|
||||||
|
public static string[] GenericMessages = [
|
||||||
|
"Config reszelés",
|
||||||
|
"Telefon, mail, chat",
|
||||||
|
"Meet, email és egyéb",
|
||||||
|
"Tanulás, dokumentálás",
|
||||||
|
"Adminisztrációs cuccok",
|
||||||
|
"Doksi készítés, tanulás",
|
||||||
|
"Doksi túrás, hibakeresés",
|
||||||
|
"Kollégákkal kommunikáció",
|
||||||
|
"Adminisztrációs feladatok",
|
||||||
|
"Napi admin körök, redmine",
|
||||||
|
"SAP dokumnetáció, önképzés",
|
||||||
|
"Általános admin és papírmunka",
|
||||||
|
"Belső egyeztetések, meetingek",
|
||||||
|
"Jegyezés, emailek, chat, stb.",
|
||||||
|
"SAP doksik olvasása, önképzés",
|
||||||
|
"Jegyek átnézése, adminisztráció",
|
||||||
|
"VPN szívás, emailek, chat, stb.",
|
||||||
|
"Saját gép karbantartása, updatek",
|
||||||
|
"Technikai utánaolvasás, research",
|
||||||
|
"SAP Note-ok böngészése, tesztelés",
|
||||||
|
"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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
Blueberry/IssueWindow.xaml
Normal file
170
Blueberry/IssueWindow.xaml
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<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"
|
||||||
|
Closing="FluentWindow_Closing"
|
||||||
|
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*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</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}" />
|
||||||
|
<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>
|
||||||
|
</ui:Card>
|
||||||
|
</DataTemplate>
|
||||||
|
</ui:ListView.ItemTemplate>
|
||||||
|
</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>
|
||||||
286
Blueberry/IssueWindow.xaml.cs
Normal file
286
Blueberry/IssueWindow.xaml.cs
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
using Blueberry.Redmine;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Interaction logic for IssueWindow.xaml
|
||||||
|
/// </summary>
|
||||||
|
public partial class IssueWindow : FluentWindow
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
_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();
|
||||||
|
await DownloadJournals();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadJournals()
|
||||||
|
{
|
||||||
|
Journals.Clear();
|
||||||
|
journalProgressRing.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, List<TimeOnIssue.TimeEntry> hours)
|
||||||
|
{
|
||||||
|
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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Visibility LockVisibility => IsPrivate ? Visibility.Visible : Visibility.Hidden;
|
||||||
|
|
||||||
|
public SolidColorBrush NameColor => StringToColorConverter.GetColorFromName(User);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
Icon="/bb.ico"
|
Icon="/bb.ico"
|
||||||
Title="Blueberry"
|
Title="Blueberry"
|
||||||
Height="720" Width="1280"
|
Height="720" Width="1280"
|
||||||
MinWidth="650" MinHeight="450"
|
MinWidth="750" MinHeight="540"
|
||||||
d:DataContext="{d:DesignInstance local:MainWindow}"
|
d:DataContext="{d:DesignInstance local:MainWindow}"
|
||||||
Loaded="WindowLoaded">
|
Loaded="WindowLoaded" WindowStartupLocation="CenterScreen">
|
||||||
<ui:FluentWindow.Resources>
|
<ui:FluentWindow.Resources>
|
||||||
<FontFamily x:Key="Roboto">/Resources/Roboto.ttf</FontFamily>
|
<FontFamily x:Key="Roboto">/Resources/Roboto.ttf</FontFamily>
|
||||||
<FontFamily x:Key="Zalando">/Resources/Zalando.ttf</FontFamily>
|
<FontFamily x:Key="Zalando">/Resources/Zalando.ttf</FontFamily>
|
||||||
@@ -35,29 +35,50 @@
|
|||||||
</ui:TitleBar.Icon>
|
</ui:TitleBar.Icon>
|
||||||
</ui:TitleBar>
|
</ui:TitleBar>
|
||||||
|
|
||||||
<Grid Grid.Row="1" Grid.Column="1">
|
<Grid x:Name="MetricDisplay" Grid.Row="1" Grid.Column="1">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="1*"/>
|
<ColumnDefinition Width="2*"/>
|
||||||
<ColumnDefinition Width="1*"/>
|
<ColumnDefinition Width="2*"/>
|
||||||
<ColumnDefinition Width="1*"/>
|
<ColumnDefinition Width="3*"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<ui:Card Margin="10, 10, 5, 10">
|
<ui:Card Margin="10, 10, 5, 10">
|
||||||
<StackPanel Orientation="Vertical">
|
<Grid>
|
||||||
<ui:TextBlock Text="Mai órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
|
<Grid.RowDefinitions>
|
||||||
<ui:TextBlock x:Name="todayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
|
<RowDefinition Height="2*" />
|
||||||
</StackPanel>
|
<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>
|
||||||
<ui:Card Grid.Column="1" Margin="5, 10, 5, 10">
|
<ui:Card Grid.Column="1" Margin="5, 10, 5, 10">
|
||||||
<StackPanel Orientation="Vertical">
|
<Grid>
|
||||||
<ui:TextBlock Text="Tegnapi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
|
<Grid.RowDefinitions>
|
||||||
<ui:TextBlock x:Name="yesterdayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
|
<RowDefinition Height="2*" />
|
||||||
</StackPanel>
|
<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>
|
||||||
<ui:Card Grid.Column="2" Margin="5, 10, 10, 10">
|
<ui:Card Grid.Column="2" Margin="5, 10, 10, 10">
|
||||||
<StackPanel Orientation="Vertical">
|
<Grid>
|
||||||
<ui:TextBlock Text="Ehavi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
|
<Grid.ColumnDefinitions>
|
||||||
<ui:TextBlock x:Name="monthTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
|
<ColumnDefinition Width="1*" />
|
||||||
</StackPanel>
|
<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>
|
</ui:Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -73,7 +94,7 @@
|
|||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<ui:SymbolIcon Symbol="Search24" Grid.Row="0" Margin="30, 10, 10, 10" />
|
<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"
|
<ui:Card Grid.Row="1" Grid.ColumnSpan="2" Margin="10, 10, 10, 10"
|
||||||
VerticalAlignment="Stretch" HorizontalAlignment="Stretch" VerticalContentAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
VerticalAlignment="Stretch" HorizontalAlignment="Stretch" VerticalContentAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||||
@@ -85,6 +106,7 @@
|
|||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="Auto" SharedSizeGroup="x"/>
|
<ColumnDefinition Width="Auto" SharedSizeGroup="x"/>
|
||||||
<ColumnDefinition Width="1*"/>
|
<ColumnDefinition Width="1*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
<ColumnDefinition Width="Auto" SharedSizeGroup="y"/>
|
<ColumnDefinition Width="Auto" SharedSizeGroup="y"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
@@ -92,28 +114,25 @@
|
|||||||
<RowDefinition Height="2*"/>
|
<RowDefinition Height="2*"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<TextBlock Grid.Column="0" Grid.Row="0" Grid.RowSpan="2" VerticalAlignment="Center" FontSize="20" FontFamily="/Resources/Inter.ttf#Inter"
|
<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"
|
<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}"
|
<TextBlock Grid.Column="1" Grid.Row="1" Grid.RowSpan="1" VerticalAlignment="Center" FontSize="10" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}"
|
||||||
Text="{Binding ProjectName}" />
|
Text="{Binding ProjectName}" />
|
||||||
<TextBlock Grid.Column="2" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="14"
|
<TextBlock Grid.Column="2" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="12"
|
||||||
Margin="20, 0, 10, 0" Text="{Binding SpentTime, StringFormat=N2}" />
|
Margin="20, 0, 10, 0" Text="{Binding StatusName}" />
|
||||||
<TextBlock Grid.Column="2" Grid.Row="1" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="10"
|
<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>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ui:ListView.ItemTemplate>
|
</ui:ListView.ItemTemplate>
|
||||||
</ui:ListView>
|
</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>
|
</ui:Card>
|
||||||
<ProgressBar x:Name="progressBar" Grid.Row="2" Grid.ColumnSpan="2" Height="10" Margin="10" VerticalAlignment="Bottom" Minimum="0" Maximum="100" Value="0"/>
|
<ProgressBar x:Name="progressBar" Grid.Row="2" Grid.ColumnSpan="2" Height="10" Margin="10" VerticalAlignment="Bottom" Minimum="0" Maximum="100" Value="0"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -156,13 +175,14 @@
|
|||||||
<ColumnDefinition Width="1*"/>
|
<ColumnDefinition Width="1*"/>
|
||||||
<ColumnDefinition Width="1*"/>
|
<ColumnDefinition Width="1*"/>
|
||||||
<ColumnDefinition Width="1*"/>
|
<ColumnDefinition Width="1*"/>
|
||||||
|
<ColumnDefinition Width="1*"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="1*"/>
|
<RowDefinition Height="1*"/>
|
||||||
<RowDefinition Height="1*"/>
|
<RowDefinition Height="1*"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<ui:Button x:Name="closeButton" Grid.Row="0" Grid.Column="0" Margin="0, 0, 5, 5" HorizontalAlignment="Stretch"
|
<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:Button.Icon>
|
||||||
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Checkmark24"/>
|
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Checkmark24"/>
|
||||||
</ui:Button.Icon>
|
</ui:Button.Icon>
|
||||||
@@ -173,14 +193,14 @@
|
|||||||
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Globe24"/>
|
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Globe24"/>
|
||||||
</ui:Button.Icon>
|
</ui:Button.Icon>
|
||||||
</ui:Button>
|
</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" >
|
VerticalAlignment="Stretch" Content="Új jegy" >
|
||||||
<ui:Button.Icon>
|
<ui:Button.Icon>
|
||||||
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="New24"/>
|
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="New24"/>
|
||||||
</ui:Button.Icon>
|
</ui:Button.Icon>
|
||||||
</ui:Button>
|
</ui:Button>
|
||||||
<ui:Button x:Name="apiButton" Grid.Row="1" Grid.Column="0" Margin="0, 5, 5, 0" HorizontalAlignment="Stretch"
|
<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:Button.Icon>
|
||||||
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Key24"/>
|
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Key24"/>
|
||||||
</ui:Button.Icon>
|
</ui:Button.Icon>
|
||||||
@@ -191,12 +211,36 @@
|
|||||||
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="ArrowRotateClockwise24"/>
|
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="ArrowRotateClockwise24"/>
|
||||||
</ui:Button.Icon>
|
</ui:Button.Icon>
|
||||||
</ui:Button>
|
</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">
|
VerticalAlignment="Stretch" Content="Fix" Click="FixButtonClick">
|
||||||
<ui:Button.Icon>
|
<ui:Button.Icon>
|
||||||
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Wrench24"/>
|
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Wrench24"/>
|
||||||
</ui:Button.Icon>
|
</ui:Button.Icon>
|
||||||
</ui:Button>
|
</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">
|
<ui:Flyout x:Name="apiFlyout" Grid.Row="1" Grid.Column="0">
|
||||||
<StackPanel Orientation="Vertical" Margin="10" Width="250">
|
<StackPanel Orientation="Vertical" Margin="10" Width="250">
|
||||||
@@ -210,6 +254,16 @@
|
|||||||
</ui:Flyout>
|
</ui:Flyout>
|
||||||
</Grid>
|
</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 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>
|
</Grid>
|
||||||
</ui:FluentWindow>
|
</ui:FluentWindow>
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
using BlueMine.Redmine;
|
using Blueberry;
|
||||||
|
using Blueberry.Redmine;
|
||||||
|
using Blueberry.Redmine.Dto;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using Wpf.Ui.Controls;
|
using Wpf.Ui.Controls;
|
||||||
using static BlueMine.Redmine.RedmineDto;
|
|
||||||
|
|
||||||
namespace BlueMine
|
namespace BlueMine
|
||||||
{
|
{
|
||||||
@@ -15,12 +16,13 @@ namespace BlueMine
|
|||||||
public partial class MainWindow : FluentWindow
|
public partial class MainWindow : FluentWindow
|
||||||
{
|
{
|
||||||
private readonly RedmineManager _manager;
|
private readonly RedmineManager _manager;
|
||||||
private readonly SettingsManager _settings;
|
private readonly RedmineSettingsManager _settings;
|
||||||
private readonly RedmineConfig _config;
|
private readonly RedmineConfig _config;
|
||||||
private List<IssueItem> _issues = [];
|
private List<IssueList.Issue> _issues = [];
|
||||||
public ObservableCollection<IssueItem> IssuesList { get; set; } = [];
|
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;
|
_settings = settings;
|
||||||
_config = config;
|
_config = config;
|
||||||
@@ -33,14 +35,44 @@ namespace BlueMine
|
|||||||
{
|
{
|
||||||
apiUrlTextBox.Text = _config.RedmineUrl;
|
apiUrlTextBox.Text = _config.RedmineUrl;
|
||||||
apiPasswordBox.PlaceholderText = new string('●', _config.ApiKey.Length);
|
apiPasswordBox.PlaceholderText = new string('●', _config.ApiKey.Length);
|
||||||
|
mainCalendar.SelectedDate = DateTime.Today;
|
||||||
|
versionTextBlock.Text = UpdateManager.CurrentVersion;
|
||||||
|
|
||||||
if(await TestConnection())
|
if(await TestConnection())
|
||||||
{
|
{
|
||||||
await LoadIssues();
|
Task loadIssuesTask = LoadIssues();
|
||||||
await GetHours();
|
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)
|
private void CalendarButtonClicked(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
flyoutCalendar.IsOpen = true;
|
flyoutCalendar.IsOpen = true;
|
||||||
@@ -51,6 +83,7 @@ namespace BlueMine
|
|||||||
var progress = new Progress<(int current, int total)>();
|
var progress = new Progress<(int current, int total)>();
|
||||||
progress.ProgressChanged += (s, args) =>
|
progress.ProgressChanged += (s, args) =>
|
||||||
{
|
{
|
||||||
|
progressRing.Visibility = Visibility.Visible;
|
||||||
int current = args.current;
|
int current = args.current;
|
||||||
int total = args.total;
|
int total = args.total;
|
||||||
statusTextBlock.Text = $"{message}: {current} / {total}";
|
statusTextBlock.Text = $"{message}: {current} / {total}";
|
||||||
@@ -76,7 +109,7 @@ namespace BlueMine
|
|||||||
|
|
||||||
private void apiLinkButton_Click(object sender, RoutedEventArgs e)
|
private void apiLinkButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
string url = "https://support.onliveit.eu:444/redmine/my/account";
|
string url = $"{apiUrlTextBox.Text}/my/account";
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
@@ -105,15 +138,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)
|
private void SearchTextBoxTextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
||||||
{
|
{
|
||||||
FilterIssues();
|
FilterIssues();
|
||||||
@@ -121,8 +145,10 @@ namespace BlueMine
|
|||||||
|
|
||||||
private async void RefreshButtonClick(object sender, RoutedEventArgs e)
|
private async void RefreshButtonClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
await LoadIssues();
|
Task loadIssuesTask = LoadIssues();
|
||||||
await GetHours();
|
Task getHoursTask = GetHours();
|
||||||
|
|
||||||
|
await Task.WhenAll(loadIssuesTask, getHoursTask);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BrowserButtonClick(object sender, RoutedEventArgs e)
|
private void BrowserButtonClick(object sender, RoutedEventArgs e)
|
||||||
@@ -130,7 +156,7 @@ namespace BlueMine
|
|||||||
var issueNum = IssueNumberTextBox.Text;
|
var issueNum = IssueNumberTextBox.Text;
|
||||||
if (int.TryParse(issueNum, out var issueId))
|
if (int.TryParse(issueNum, out var issueId))
|
||||||
{
|
{
|
||||||
string url = $"https://support.onliveit.eu:444/redmine/issues/{issueId}";
|
string url = $"{_config.RedmineUrl}/issues/{issueId}";
|
||||||
|
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
@@ -143,43 +169,23 @@ namespace BlueMine
|
|||||||
|
|
||||||
private async void CloseButtonClick(object sender, RoutedEventArgs e)
|
private async void CloseButtonClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var issueNum = IssueNumberTextBox.Text;
|
StatusList.Clear();
|
||||||
if (int.TryParse(issueNum, out var issueId))
|
var s = await _manager.GetStatusesAsync();
|
||||||
{
|
foreach (var status in s)
|
||||||
try
|
StatusList.Add(status);
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Wpf.Ui.Controls.MessageBox
|
statusFlyout.IsOpen = true;
|
||||||
{
|
|
||||||
Title = "Hiba",
|
|
||||||
Content = "Érvénytelen jegyszám.",
|
|
||||||
}.ShowDialogAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void FixButtonClick(object sender, RoutedEventArgs e)
|
private async void FixButtonClick(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var progress = UpdateProgress("Idők javítása:");
|
var progress = UpdateProgress("Idők javítása:");
|
||||||
|
progressRing.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
var i = 0;
|
var i = 0;
|
||||||
foreach (var date in mainCalendar.SelectedDates)
|
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)
|
if (hours <= 0)
|
||||||
continue;
|
continue;
|
||||||
var message = Constants.GenericMessages[Random.Shared.Next(Constants.GenericMessages.Length)];
|
var message = Constants.GenericMessages[Random.Shared.Next(Constants.GenericMessages.Length)];
|
||||||
@@ -189,6 +195,7 @@ namespace BlueMine
|
|||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
progressBar.Value = 0;
|
progressBar.Value = 0;
|
||||||
|
progressRing.Visibility = Visibility.Hidden;
|
||||||
await GetHours();
|
await GetHours();
|
||||||
statusTextBlock.Text = "Idők javítva";
|
statusTextBlock.Text = "Idők javítva";
|
||||||
|
|
||||||
@@ -196,6 +203,16 @@ namespace BlueMine
|
|||||||
|
|
||||||
private async void sendButton_Click(object sender, RoutedEventArgs e)
|
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)
|
if(int.TryParse(IssueNumberTextBox.Text, out var issueId)
|
||||||
&& double.TryParse(HoursTextBox.Text, out var hours))
|
&& double.TryParse(HoursTextBox.Text, out var hours))
|
||||||
{
|
{
|
||||||
@@ -209,16 +226,17 @@ namespace BlueMine
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var total = mainCalendar.SelectedDates.Count;
|
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++)
|
for (int i = 0; i < total; i++)
|
||||||
{
|
{
|
||||||
await _manager.LogTimeAsync(issueId, hours, MessageTextBox.Text, mainCalendar.SelectedDates[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();
|
await GetHours();
|
||||||
|
progressBar.Value = 0;
|
||||||
|
progressRing.Visibility = Visibility.Hidden;
|
||||||
|
statusTextBlock.Text = "Idők beküldve";
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
await new Wpf.Ui.Controls.MessageBox
|
await new Wpf.Ui.Controls.MessageBox
|
||||||
@@ -233,11 +251,131 @@ namespace BlueMine
|
|||||||
private void ListView_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
private void ListView_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
var lv = sender as ListView;
|
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)
|
||||||
|
{
|
||||||
|
var hoursWindow = new HoursWindow(_manager, _config);
|
||||||
|
hoursWindow.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
UpdateManager_DownloadCompleted();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class MainWindow : FluentWindow
|
public partial class MainWindow : FluentWindow
|
||||||
@@ -295,22 +433,43 @@ namespace BlueMine
|
|||||||
|
|
||||||
public async Task GetHours()
|
public async Task GetHours()
|
||||||
{
|
{
|
||||||
var today = await _manager.GetLoggedHoursAsync(DateTime.Today, DateTime.Today);
|
todayProgressRing.Visibility =
|
||||||
var yesterday = await _manager.GetLoggedHoursAsync(DateTime.Today.AddDays(-1), DateTime.Today.AddDays(-1));
|
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);
|
int workingDays = 0;
|
||||||
var thisMonth = await _manager.GetLoggedHoursAsync(m, m.AddMonths(1).AddDays(-1));
|
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();
|
todayTimeLabel.Text = today.ToString();
|
||||||
yesterdayTimeLabel.Text = yesterday.ToString();
|
yesterdayTimeLabel.Text = yesterday.ToString();
|
||||||
monthTimeLabel.Text = thisMonth.ToString();
|
monthTimeLabel.Text = thisMonth.ToString();
|
||||||
|
averageTimeLabel.Text = avgHours.ToString();
|
||||||
|
|
||||||
|
todayProgressRing.Visibility =
|
||||||
|
yesterdayProgressRing.Visibility =
|
||||||
|
monthProgressRing.Visibility = Visibility.Hidden;
|
||||||
}
|
}
|
||||||
public void FilterIssues()
|
public void FilterIssues()
|
||||||
{
|
{
|
||||||
var list = string.IsNullOrWhiteSpace(searchTextBox.Text)
|
var list = string.IsNullOrWhiteSpace(searchTextBox.Text)
|
||||||
? _issues
|
? _issues
|
||||||
: _issues.Where(issue => issue.IssueName.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase)
|
: _issues.Where(issue => issue.Subject.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase)
|
||||||
|| issue.IssueNumber.ToString().Contains(searchTextBox.Text)
|
|| issue.Id.ToString().Contains(searchTextBox.Text)
|
||||||
|| issue.ProjectName.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase));
|
|| issue.ProjectName.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase));
|
||||||
IssuesList.Clear();
|
IssuesList.Clear();
|
||||||
foreach (var item in list)
|
foreach (var item in list)
|
||||||
@@ -321,9 +480,14 @@ namespace BlueMine
|
|||||||
public async Task LoadIssues()
|
public async Task LoadIssues()
|
||||||
{
|
{
|
||||||
_issues.Clear();
|
_issues.Clear();
|
||||||
_issues.AddRange(Constants.StaticTickets);
|
statusTextBlock.Text = "Jegyek letöltése...";
|
||||||
_issues.AddRange(await _manager.GetCurrentIssuesAsync(UpdateProgress("Jegyek letöltése:")));
|
progressRing.Visibility = Visibility.Visible;
|
||||||
|
foreach (var issueId in Constants.StaticTickets)
|
||||||
|
_issues.Add(await _manager.GetSimpleIssueAsync(issueId));
|
||||||
|
|
||||||
|
_issues.AddRange(await _manager.GetCurrentUserOpenIssuesAsync(progress: UpdateProgress("Jegyek letöltése:")));
|
||||||
progressBar.Value = 0;
|
progressBar.Value = 0;
|
||||||
|
progressRing.Visibility = Visibility.Hidden;
|
||||||
FilterIssues();
|
FilterIssues();
|
||||||
statusTextBlock.Text = "Jegyek letöltve";
|
statusTextBlock.Text = "Jegyek letöltve";
|
||||||
}
|
}
|
||||||
@@ -359,19 +523,49 @@ namespace BlueMine
|
|||||||
}
|
}
|
||||||
public async Task<bool> TestConnection()
|
public async Task<bool> TestConnection()
|
||||||
{
|
{
|
||||||
if (!await _manager.IsRedmineAvailable())
|
statusTextBlock.Text = $"Kapcsolódás Redminehoz...";
|
||||||
|
|
||||||
|
int maxRetries = 3;
|
||||||
|
int timeoutSeconds = 3; // Force kill after 5s
|
||||||
|
|
||||||
|
for (int i = 0; i < maxRetries; i++)
|
||||||
{
|
{
|
||||||
DisableUi();
|
try
|
||||||
apiButton.Appearance = Wpf.Ui.Controls.ControlAppearance.Primary;
|
{
|
||||||
return false;
|
// Creates a token that cancels automatically
|
||||||
}
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
|
||||||
else
|
|
||||||
{
|
// Pass the token. If it hangs, this throws OperationCanceledException
|
||||||
EnableUi();
|
if (await _manager.IsRedmineAvailable(cts.Token))
|
||||||
apiButton.Appearance = Wpf.Ui.Controls.ControlAppearance.Secondary;
|
{
|
||||||
statusTextBlock.Text = "Kapcsolódva";
|
EnableUi();
|
||||||
return true;
|
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(5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
80
Blueberry/StringToColorConverter.cs
Normal file
80
Blueberry/StringToColorConverter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
Blueberry/TimeTrackerWindow.xaml
Normal file
65
Blueberry/TimeTrackerWindow.xaml
Normal 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>
|
||||||
108
Blueberry/TimeTrackerWindow.xaml.cs
Normal file
108
Blueberry/TimeTrackerWindow.xaml.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
Blueberry/UpdateManager.cs
Normal file
183
Blueberry/UpdateManager.cs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Blueberry
|
||||||
|
{
|
||||||
|
public static class UpdateManager
|
||||||
|
{
|
||||||
|
private const string releaseUrl = "https://git.technopunk.space/api/v1/repos/tomi/Blueberry/releases/latest";
|
||||||
|
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 static async Task<bool> IsUpdateAvailable()
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
// 1. Use await here, don't block
|
||||||
|
var json = await client.GetStringAsync(releaseUrl);
|
||||||
|
var release = JsonSerializer.Deserialize<Root>(json);
|
||||||
|
|
||||||
|
if (release == null) return;
|
||||||
|
|
||||||
|
if (release.tag_name != CurrentVersion)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 currentExe = Process.GetCurrentProcess().MainModule.FileName;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 3. Execute the PowerShell script hidden
|
||||||
|
var startInfo = new ProcessStartInfo()
|
||||||
|
{
|
||||||
|
FileName = "powershell.exe",
|
||||||
|
Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{psPath}\"",
|
||||||
|
UseShellExecute = true,
|
||||||
|
CreateNoWindow = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Normal
|
||||||
|
};
|
||||||
|
|
||||||
|
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 long size { get; set; }
|
||||||
|
public string browser_download_url { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
@@ -1,24 +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>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="bb.ico">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Include="AppPayload.zip">
|
|
||||||
<Visible>false</Visible>
|
|
||||||
</EmbeddedResource>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.IO.Compression;
|
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
string resourceName = "BlueberryUpdater.AppPayload.zip"; // Format: Namespace.Filename
|
|
||||||
|
|
||||||
Console.WriteLine($"Installing to {installPath}...");
|
|
||||||
|
|
||||||
// 1. Clean existing install if necessary
|
|
||||||
if (Directory.Exists(installPath))
|
|
||||||
{
|
|
||||||
Directory.Delete(installPath, true);
|
|
||||||
}
|
|
||||||
Directory.CreateDirectory(installPath);
|
|
||||||
|
|
||||||
// 2. Extract Embedded Resource
|
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
|
||||||
|
|
||||||
using (Stream stream = assembly.GetManifestResourceStream(resourceName))
|
|
||||||
{
|
|
||||||
if (stream == null)
|
|
||||||
{
|
|
||||||
throw new Exception($"Resource '{resourceName}' not found. Check Embedded Resource settings.");
|
|
||||||
}
|
|
||||||
|
|
||||||
using (ZipArchive archive = new(stream))
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MoveUpdater();
|
|
||||||
|
|
||||||
DrawProgressBar(100, "Done");
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
// 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 - 1; // 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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
55
README.md
Normal file
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 🫐 Blueberry
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Egy modern Redmine kliens asztali alkalmazás Windowsra, amely egyszerűsíti a projektmenedzsmentet és időkövetést.
|
||||||
|
|
||||||
|
## Mit tud a rendszer?
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## Telepítés
|
||||||
|
|
||||||
|
### Automatikus (ajánlott)
|
||||||
|
|
||||||
|
Nyiss egy PowerShell terminált és futtasd a következő parancsot:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
iwr https://blueberry.technopunk.space | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kézi telepítés
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## Használat
|
||||||
|
|
||||||
|
### Első alkalommal
|
||||||
|
|
||||||
|
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!
|
||||||
|
|
||||||
|
### API kulcs beszerzése
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## Rendszerkövetelmények
|
||||||
|
|
||||||
|
- Windows 10 vagy újabb
|
||||||
|
- Internetkapcsolat a Redmine szerverhez
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Configuration
|
|
||||||
$wpfProject = ".\BlueMine\BlueMine.csproj"
|
|
||||||
$installerProject = ".\BlueberryUpdater\BlueberryUpdater.csproj"
|
|
||||||
$installerDir = ".\BlueberryUpdater"
|
|
||||||
$zipPath = "$installerDir\AppPayload.zip"
|
|
||||||
$outputDir = ".\FinalBuild"
|
|
||||||
|
|
||||||
# 1. Clean up previous artifacts
|
|
||||||
Write-Host "Cleaning up..." -ForegroundColor Cyan
|
|
||||||
if (Test-Path $zipPath) { Remove-Item $zipPath }
|
|
||||||
if (Test-Path $outputDir) { Remove-Item $outputDir -Recurse }
|
|
||||||
if (Test-Path ".\TempWpfPublish") { Remove-Item ".\TempWpfPublish" -Recurse }
|
|
||||||
|
|
||||||
# 2. Publish WPF App (Self-Contained)
|
|
||||||
Write-Host "Publishing WPF App..." -ForegroundColor Cyan
|
|
||||||
dotnet publish $wpfProject -c Release -r win-x64 --self-contained true -o ".\TempWpfPublish" /p:DebugType=None /p:DebugSymbols=false
|
|
||||||
|
|
||||||
# 3. Zip the Published WPF App
|
|
||||||
Write-Host "Creating Payload Zip..." -ForegroundColor Cyan
|
|
||||||
Compress-Archive -Path ".\TempWpfPublish\*" -DestinationPath $zipPath
|
|
||||||
|
|
||||||
# 4. Publish Installer (Single File + Embeds the Zip)
|
|
||||||
Write-Host "Building Final Installer..." -ForegroundColor Cyan
|
|
||||||
dotnet publish $installerProject -c Release -r win-x64 --self-contained true -o $outputDir -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
|
|
||||||
|
|
||||||
# 5. Cleanup Temp Files
|
|
||||||
Remove-Item ".\TempWpfPublish" -Recurse
|
|
||||||
# Optional: Remove the zip from the source folder if you want to keep it clean
|
|
||||||
# Remove-Item $zipPath
|
|
||||||
|
|
||||||
Write-Host "Build Complete! Installer is in $outputDir" -ForegroundColor Green
|
|
||||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
21
install.ps1
Normal file
21
install.ps1
Normal 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
|
||||||
Reference in New Issue
Block a user