37 Commits

Author SHA1 Message Date
9fd3bb7fa5 bump 0.2.7 2026-01-15 20:07:10 +01:00
67cdfb0c21 fix(debug): hide debug console 2026-01-15 20:06:48 +01:00
ddc7206a73 optim(redmine): hour stats are also improved 2026-01-15 20:03:45 +01:00
d152b62cc4 optim(redmine): made most requests async 2026-01-15 16:31:53 +01:00
0b6df0c508 bump 0.2.2 2025-12-17 12:34:27 +01:00
ec077240a4 fix update button, bump 0.2.1 2025-12-17 12:32:24 +01:00
56fd7a6da7 bump 0.2.0 2025-12-17 12:23:56 +01:00
db063e61b1 add commenting on issues 2025-12-17 12:23:29 +01:00
a86ad23774 add loading progress to hours window 2025-12-17 12:23:21 +01:00
b68c43af38 bump 0.1.9 2025-12-16 21:58:22 +01:00
d15e01f253 bump 0.1.8 2025-12-16 21:48:10 +01:00
dc22000c79 add hour window 2025-12-16 21:47:49 +01:00
bd31fb6eb0 bump 0.1.7 2025-12-16 06:03:51 +01:00
ac787d1976 add update button 2025-12-16 06:03:32 +01:00
d484b55823 bump 0.1.6 2025-12-16 05:38:40 +01:00
1822fd24a1 bump 0.1.5 2025-12-16 05:36:18 +01:00
136ceb1a9b fix updater 2025-12-16 05:35:22 +01:00
7811b36edf fix startup hang 2025-12-16 05:14:25 +01:00
f9cbb69939 update README 2025-12-16 05:09:42 +01:00
4eadf368f2 fix update script error 2025-12-15 13:57:33 +01:00
76cfb440b9 add hours to issue details (1.1.3) 2025-12-15 12:07:13 +01:00
ad6ca741e8 Remove unused usings 2025-12-15 11:22:21 +01:00
c6097ab6dc add partial background downloading and update prompt 2025-12-15 11:03:30 +01:00
5cb7895e24 Merge branch 'main' of git.technopunk.space:tomi/Blueberry 2025-12-15 10:03:44 +01:00
21acb733b6 add version info to main window 2025-12-15 10:03:38 +01:00
a946929c3e Update install.ps1 2025-12-15 08:35:13 +00:00
9254b4cf2f Update README.md 2025-12-15 08:33:17 +00:00
fbf3b6826c complete refactor 2025-12-15 09:26:27 +01:00
41c7ec292c restructure project 2025-12-10 19:10:00 +01:00
37984f0631 fix downloader 2025-12-10 13:38:45 +01:00
22ded9029c update installer to use git releases 2025-12-10 13:15:05 +01:00
e2fe68a29a Delete build.installer.ps1 2025-12-10 11:03:10 +00:00
ba4e31334f remove installer script 2025-12-10 12:02:56 +01:00
31ff035b5e Update .gitignore 2025-12-10 10:56:47 +00:00
fcaf8718c7 Delete BlueberryUpdater/AppPayload.zip 2025-12-10 10:56:32 +00:00
7bf15ce021 Merge branch 'main' of git.technopunk.space:tomi/Blueberry 2025-12-10 11:55:43 +01:00
b08df1568c fix hardcoded values 2025-12-10 11:55:27 +01:00
58 changed files with 4022 additions and 1483 deletions

3
.gitignore vendored
View File

@@ -2,13 +2,14 @@
## files generated by popular Visual Studio add-ons.
# User-specific files
build.installer.ps1
*.suo
*.user
*.sln.docstates
.vs/
# Build results
./BlueberryUpdater/*.zip
BlueberryUpdater/*.zip
FinalBuild/
[Dd]ebug/
[Rr]elease/

View File

@@ -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ó",
];
}
}

View File

@@ -1,29 +0,0 @@
namespace BlueMine.Redmine
{
public class AsyncLock
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task<IDisposable> LockAsync()
{
await _semaphore.WaitAsync();
return new Disposable(() => _semaphore.Release());
}
private class Disposable : IDisposable
{
private readonly Action _action;
public Disposable(Action action)
{
_action = action;
}
public void Dispose()
{
_action();
}
}
}
}

View File

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

View File

@@ -1,18 +0,0 @@
using static BlueMine.Redmine.RedmineDto;
namespace BlueMine.Redmine
{
public interface IRedmineConnect
{
Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null);
Task CloseIssueAsync(int issueId);
Task<int> CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null);
Task<IEnumerable<SimpleProject>> GetProjectsAsync(int limit = 25, IProgress<(int, int)>? progress = null);
Task<List<SimpleTracker>> GetTrackersAsync(string projectId, CancellationToken? token = null);
Task VerifyApiKey();
Task<IEnumerable<SimpleIssue>> GetMyIssuesAsync(int limit = 25, IProgress<(int, int)>? progress = null);
Task<double> GetIssueTotalTimeAsync(int issueId);
Task<double> GetTodaysHoursAsync(DateTime startDate, DateTime endDate);
Task<List<IssueItem>> GetSpentTimeForIssuesAsync(List<SimpleIssue> simpleIssues, IProgress<(int, int)>? progress = null);
}
}

View File

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

View File

@@ -1,51 +0,0 @@
using Microsoft.Extensions.Logging;
using System.Net.Http;
namespace BlueMine.Redmine
{
public class RedmineAuthHandler : DelegatingHandler
{
private readonly RedmineConfig _config;
private readonly ILogger<RedmineAuthHandler> _logger;
public RedmineAuthHandler(RedmineConfig config, ILogger<RedmineAuthHandler> logger)
{
_logger = logger;
_config = config;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
_logger.LogDebug("Checking config for valid options");
if (!string.IsNullOrWhiteSpace(_config.ApiKey))
{
_logger.LogDebug("Refreshing API key");
request.Headers.Remove("X-Redmine-API-Key");
request.Headers.Add("X-Redmine-API-Key", _config.ApiKey);
}
if (!string.IsNullOrWhiteSpace(_config.RedmineUrl)
&& request.RequestUri != null)
{
_logger.LogDebug("Refreshing base URI");
string baseUrlStr = _config.RedmineUrl.EndsWith("/")
? _config.RedmineUrl
: _config.RedmineUrl + "/";
var baseUri = new Uri(baseUrlStr);
string pathAndQuery = request.RequestUri.PathAndQuery;
if (pathAndQuery.StartsWith("/"))
{
pathAndQuery = pathAndQuery.Substring(1);
}
request.RequestUri = new Uri(baseUri, pathAndQuery);
}
return await base.SendAsync(request, cancellationToken);
}
}
}

View File

@@ -1,64 +0,0 @@
using Microsoft.Extensions.Logging;
namespace BlueMine.Redmine
{
class RedmineCache<T> : IRedmineCache<T>
{
private List<T> Items { get; set; } = [];
public DateTime LastUpdated { get; set; } = DateTime.MinValue;
private readonly ILogger _logger;
private readonly TimeSpan _cacheDuration;
private readonly object _lock = new();
public RedmineCache(TimeSpan cacheDuration, ILogger<RedmineCache<T>> logger)
{
_logger = logger;
_cacheDuration = cacheDuration;
}
public RedmineCache(int cacheDurationSec, ILogger<RedmineCache<T>> logger)
{
_logger = logger;
_cacheDuration = new TimeSpan(0, 0, cacheDurationSec);
}
public void RefreshCache(List<T> newItems)
{
_logger.LogDebug($"Refreshing cache with {newItems.Count} items");
lock (_lock)
{
Items = newItems;
LastUpdated = DateTime.UtcNow;
_logger.LogDebug("Cache refreshed");
}
}
public void InvalidateCache()
{
_logger.LogDebug("Invalidating cache");
lock (_lock)
{
LastUpdated = DateTime.MinValue;
_logger.LogDebug("Cache invalidated");
}
}
public bool IsCacheValid()
{
lock (_lock)
{
bool valid = DateTime.UtcNow - LastUpdated <= _cacheDuration;
_logger.LogDebug($"Cache valid: {valid}");
return valid;
}
}
public List<T> GetItems()
{
lock (_lock)
{
_logger.LogDebug($"Returning {Items.Count} cached items");
return Items;
}
}
}
}

View File

@@ -1,379 +0,0 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Collections.Concurrent;
using static BlueMine.Redmine.RedmineDto;
using Microsoft.Extensions.Logging;
namespace BlueMine.Redmine
{
public class RedmineConnect : IRedmineConnect
{
readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
private readonly SemaphoreSlim _concurrencySemaphore;
private readonly RedmineConfig _config;
private readonly ILogger<RedmineConnect> _logger;
private int _userId = -1;
public RedmineConnect(HttpClient client, ILogger<RedmineConnect> logger, RedmineConfig config)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(config);
_httpClient = client;
_logger = logger;
_config = config;
_concurrencySemaphore = new(config.ConcurrencyLimit, config.ConcurrencyLimit);
}
private async Task<TResponse?> SendRequestAsync<TResponse>(HttpMethod method, string endpoint, object? payload = null, CancellationToken? token = null)
{
string url = $"{_config.RedmineUrl}/{endpoint}";
int maxRetries = _config.MaxRetries;
int retryDelayMilliseconds = 2000;
CancellationToken cancellationToken = token ?? CancellationToken.None;
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
using var request = new HttpRequestMessage(method, url);
request.Headers.Add("X-Redmine-API-Key", _config.ApiKey);
if (payload != null)
{
string json = JsonSerializer.Serialize(payload, _jsonOptions);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
if(cancellationToken.IsCancellationRequested)
{
_logger.LogInformation("Request cancelled by token");
cancellationToken.ThrowIfCancellationRequested();
}
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
if (response.Content.Headers.ContentLength == 0)
return default;
var responseStream = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<TResponse>(responseStream, _jsonOptions);
}
bool isServerError = (int)response.StatusCode >= 500 && (int)response.StatusCode < 600;
if (isServerError && attempt < maxRetries)
{
_logger.LogWarning("Received {StatusCode} from Redmine. Retrying in {Delay}ms (Attempt {Attempt} of {MaxRetries})", response.StatusCode, retryDelayMilliseconds, attempt + 1, maxRetries);
response.Dispose();
await Task.Delay(retryDelayMilliseconds).ConfigureAwait(false);
continue;
}
string errorBody = await response.Content.ReadAsStringAsync();
response.Dispose();
_logger.LogError("Error ({StatusCode}): {ErrorBody}", response.StatusCode, errorBody);
throw new RedmineApiException($"Redmine API Error {response.StatusCode}: {errorBody}", (int)response.StatusCode);
}
throw new RedmineApiException("Redmine API Unreachable");
}
public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null)
{
string url = $"time_entries.json";
string dateString = (date ?? DateTime.Now).ToString("yyyy-MM-dd");
var payload = new TimeEntryRequest
{
TimeEntry = new TimeEntry
{
IssueId = issueId,
Hours = hours,
Comments = comments,
SpentOn = dateString,
ActivityId = activityId
}
};
var response = await SendRequestAsync<object>(HttpMethod.Post, url, payload);
}
public async Task CloseIssueAsync(int issueId)
{
_logger.LogDebug("Closing issue {IssueId}", issueId);
_logger.LogInformation("Closing issue {IssueId}", issueId);
string url = $"issues/{issueId}.json";
var payload = new
{
issue = new
{
status_id = RedmineConstants.ClosedStatusId
}
};
var response = await SendRequestAsync<object>(HttpMethod.Put, url, payload);
_logger.LogInformation("Closed {IssueId}", issueId);
_logger.LogDebug("Issue {IssueId} closed successfully", issueId);
}
public async Task<int> CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null, double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null)
{
_logger.LogDebug("Creating issue in project {ProjectId} with subject '{Subject}'", projectId, subject);
if (_userId == -1)
{
await VerifyApiKey();
}
string url = $"issues.json";
var issueDict = new Dictionary<string, object>
{
["project_id"] = projectId,
["tracker_id"] = trackerId,
["subject"] = subject,
["assigned_to_id"] = _userId
};
if (!string.IsNullOrEmpty(description))
issueDict["description"] = description;
if (estimatedHours.HasValue)
issueDict["estimated_hours"] = estimatedHours.Value;
if (priorityId.HasValue)
issueDict["priority_id"] = priorityId.Value;
if (parentIssueId.HasValue)
issueDict["parent_issue_id"] = parentIssueId.Value;
if (estimatedHours.HasValue)
{
issueDict["custom_fields"] = new[]
{
new
{
id = RedmineConstants.EstimatedHoursCustomFieldId,
value = estimatedHours.Value.ToString()
}
};
}
var issue = issueDict;
var payload = new
{
issue
};
var response = await SendRequestAsync<CreateIssueResponse>(HttpMethod.Post, url, payload);
var issueId = response?.Issue?.Id ?? throw new Exception("Failed to parse created issue response");
_logger.LogInformation("Issue {IssueId} created with subject '{Subject}'", issueId, subject);
_logger.LogDebug("Issue {IssueId} created successfully", issueId);
return issueId;
}
public async Task<IEnumerable<SimpleProject>> GetProjectsAsync(int limit = 25, IProgress<(int, int)>? progress = null)
{
int offset = 0;
int totalCount = 0;
var projects = new ConcurrentBag<SimpleProject>();
while (true)
{
string url = $"projects.json?limit={limit}&offset={offset}";
var response = await SendRequestAsync<ProjectListResponse>(HttpMethod.Get, url);
if (response?.Projects != null)
{
totalCount = response.TotalCount;
foreach (var p in response.Projects)
{
projects.Add(new SimpleProject
{
Id = p.Id,
Name = p.Name,
Identifier = p.Identifier
});
}
progress?.Report((offset + response.Projects.Count, totalCount));
}
if (response == null || offset + limit >= totalCount)
break;
offset += limit;
}
_logger.LogInformation("Fetched projects from API");
return projects;
}
public async Task<List<SimpleTracker>> GetTrackersAsync(string projectId, CancellationToken? token = null)
{
string url = $"projects/{projectId}.json?include=trackers";
var response = await SendRequestAsync<ProjectWithTrackersResponse>(HttpMethod.Get, url, token: token);
var trackers = response?.Project?.Trackers.Select(t => new SimpleTracker
{
Id = t.Id,
Name = t.Name
}).ToList() ?? new List<SimpleTracker>();
_logger.LogInformation("Fetched {Count} trackers from API", trackers.Count);
return trackers;
}
public async Task VerifyApiKey()
{
_logger.LogDebug("Verifying API key");
_logger.LogInformation("Verifying API Key and fetching user ID");
const int maxAttempts = 3;
for (int attempts = 0; attempts < maxAttempts; attempts++)
{
string url = $"issues.json?assigned_to_id=me&status_id=open&limit=1";
var response = await SendRequestAsync<IssueListResponse>(HttpMethod.Get, url);
var userid = response?.Issues.FirstOrDefault()?.AssignedTo?.Id;
if (userid != null && userid != -1)
{
_userId = userid.Value;
_logger.LogInformation("API Key verified. User ID: {UserId}", _userId);
_logger.LogDebug("User ID set to {UserId}", _userId);
return;
}
_logger.LogDebug("User ID not found, retrying (attempt {Attempt}/{Max})", attempts + 1, maxAttempts);
if (attempts < maxAttempts - 1)
{
await Task.Delay(1000); // short delay
}
}
throw new InvalidOperationException("Failed to verify API key after maximum attempts");
}
public async Task<IEnumerable<SimpleIssue>> GetMyIssuesAsync(int limit = 25, IProgress<(int, int)>? progress = null)
{
var offset = 0;
int totalCount = 0;
var issues = new ConcurrentBag<SimpleIssue>();
while(true)
{
string url = $"issues.json?assigned_to_id=me&status_id=open&limit={limit}&offset={offset}";
var response = await SendRequestAsync<IssueListResponse>(HttpMethod.Get, url);
if (response?.Issues != null)
{
totalCount = response.TotalCount;
foreach (var i in response.Issues)
{
issues.Add(new SimpleIssue
{
Id = i.Id,
ProjectName = i.Project?.Name ?? "Unknown",
Subject = i.Subject,
Description = i.Description,
Created = i.Created,
Updated = i.Updated
});
}
progress?.Report((offset + response.Issues.Count, totalCount));
}
if (response == null || offset + limit >= totalCount)
break;
offset += limit;
}
_logger.LogInformation("Fetched issues from API");
return issues;
}
public async Task<double> GetIssueTotalTimeAsync(int issueId)
{
string url = $"time_entries.json?issue_id={issueId}&limit=100";
var response = await SendRequestAsync<TimeEntryListResponse>(HttpMethod.Get, url);
if (response?.TimeEntries != null)
{
return response.TimeEntries.Sum(t => t.Hours);
}
return 0.0;
}
public async Task<double> GetTodaysHoursAsync(DateTime startDate, DateTime endDate)
{
_logger.LogDebug("Getting hours from {StartDate} to {EndDate}", startDate.ToShortDateString(), endDate.ToShortDateString());
string start = startDate.ToString("yyyy-MM-dd");
string end = endDate.ToString("yyyy-MM-dd");
string url = $"time_entries.json?from={start}&to={end}&user_id={_userId}";
var response = await SendRequestAsync<TimeEntryListResponse>(HttpMethod.Get, url);
double total = response?.TimeEntries?.Sum(t => t.Hours) ?? 0;
_logger.LogInformation("Fetched hours: {Total}", total);
_logger.LogDebug("Total hours: {Total}", total);
return total;
}
public async Task<List<IssueItem>> GetSpentTimeForIssuesAsync(List<SimpleIssue> simpleIssues, IProgress<(int, int)>? progress = null)
{
_logger.LogDebug("Getting current issues with spent time");
_logger.LogDebug("Retrieved {Count} simple issues", simpleIssues.Count);
var issueItems = new ConcurrentBag<IssueItem>();
var tasks = new List<Task>();
for (int i = 0; i < simpleIssues.Count; i++)
{
SimpleIssue si = simpleIssues[i];
var task = Task.Run(async () =>
{
await _concurrencySemaphore.WaitAsync();
try
{
var spent = await GetIssueTotalTimeAsync(si.Id);
issueItems.Add(new IssueItem
{
ProjectName = si.ProjectName,
IssueName = si.Subject,
IssueNumber = si.Id,
IssueDescription = si.Description,
Updated = si.Updated,
Created = si.Created,
SpentTime = spent
});
_logger.LogDebug("Retrieved total time for issue {IssueId}: {Spent} hours", si.Id, spent);
}
finally
{
_concurrencySemaphore.Release();
progress?.Report((issueItems.Count, simpleIssues.Count));
}
});
tasks.Add(task);
}
await Task.WhenAll(tasks);
_logger.LogInformation("Processed {Count} issues with spent time", issueItems.Count);
_logger.LogDebug("Finished processing issues");
return [.. issueItems];
}
}
}

View File

@@ -1,185 +0,0 @@
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
using System.Text.Json.Serialization;
namespace BlueMine.Redmine
{
public static class RedmineConstants
{
public const int ClosedStatusId = 30;
public const int EstimatedHoursCustomFieldId = 65;
}
public class RedmineDto
{
public class SimpleIssue
{
public int Id { get; set; }
public string ProjectName { get; set; }
public string Subject { get; set; }
public string Description { get; set; }
public DateTime Created { get; set; }
public DateTime Updated { get; set; }
}
public class SimpleProject
{
public int Id { get; set; }
public string Name { get; set; }
public string Identifier { get; set; }
}
public class SimpleTracker
{
public int Id { get; set; }
public string Name { get; set; }
}
public class IssueItem
{
public string ProjectName { get; set; }
public string IssueName { get; set; }
public string IssueDescription { get; set; }
public int IssueNumber { get; set; }
public double SpentTime { get; set; }
public DateTime Created { get; set; }
public DateTime Updated { get; set; }
public string LastUpdate { get
{
var span = DateTime.Now - Updated;
if (span.TotalMinutes < 1) return "épp most";
if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes} perce";
if (span.TotalHours < 24) return $"{(int)span.TotalHours} órája";
if (span.TotalDays < 7) return $"{(int)span.TotalDays} napja";
if (span.TotalDays < 30) return $"{(int)(span.TotalDays / 7)} hete";
if (span.TotalDays < 365) return $"{(int)(span.TotalDays / 30)} hónapja";
return $"{(int)(span.TotalDays / 365)} éve";
}
}
}
public class TimeEntryRequest
{
[JsonPropertyName("time_entry")]
public TimeEntry TimeEntry { get; set; }
}
public class TimeEntry
{
[JsonPropertyName("issue_id")]
public int IssueId { get; set; }
[JsonPropertyName("hours")]
public double Hours { get; set; }
[JsonPropertyName("comments")]
public string Comments { get; set; }
[JsonPropertyName("spent_on")]
public string SpentOn { get; set; }
[JsonPropertyName("activity_id")]
public int? ActivityId { get; set; }
}
public class IssueListResponse
{
[JsonPropertyName("issues")]
public List<IssueDto> Issues { get; set; }
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
[JsonPropertyName("limit")]
public int Limit { get; set; }
[JsonPropertyName("offset")]
public int Offset { get; set; }
}
public class CreateIssueResponse
{
[JsonPropertyName("issue")]
public IssueDto Issue { get; set; }
}
public class IssueDto
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("project")]
public IdNameDto Project { get; set; }
[JsonPropertyName("subject")]
public string Subject { get; set; }
[JsonPropertyName("assigned_to")]
public AssignedToDto AssignedTo { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("created_on")]
public DateTime Created { get; set; }
[JsonPropertyName("updated_on")]
public DateTime Updated { get; set; }
}
public class IdNameDto
{
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class AssignedToDto
{
[JsonPropertyName("id")]
public int Id { get; set; }
}
public class TimeEntryListResponse
{
[JsonPropertyName("time_entries")]
public List<TimeEntryDto> TimeEntries { get; set; }
}
public class TimeEntryDto
{
[JsonPropertyName("hours")]
public double Hours { get; set; }
}
public class ProjectListResponse
{
[JsonPropertyName("projects")]
public List<ProjectDto> Projects { get; set; }
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
[JsonPropertyName("limit")]
public int Limit { get; set; }
[JsonPropertyName("offset")]
public int Offset { get; set; }
}
public class ProjectDto
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("identifier")]
public string Identifier { get; set; }
}
public class ProjectWithTrackersResponse
{
[JsonPropertyName("project")]
public ProjectWithTrackersDto Project { get; set; }
}
public class ProjectWithTrackersDto
{
[JsonPropertyName("trackers")]
public List<TrackerDto> Trackers { get; set; }
}
public class TrackerDto
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.

View File

@@ -1,206 +0,0 @@
using Microsoft.Extensions.Logging;
using System.Net.Http;
using static BlueMine.Redmine.RedmineDto;
namespace BlueMine.Redmine
{
public class RedmineManager : IRedmineManager
{
private readonly RedmineConnect _redmineConnect;
private readonly AsyncLock _lock = new();
private readonly RedmineCache<SimpleProject> _projectCache;
private readonly RedmineCache<SimpleIssue> _issueCache;
private readonly ILogger<RedmineManager> _logger;
public RedmineManager(HttpClient httpClient, ILoggerFactory loggerFactory, RedmineConfig config)
{
ArgumentNullException.ThrowIfNull(httpClient);
ArgumentNullException.ThrowIfNull(loggerFactory);
ArgumentNullException.ThrowIfNull(config);
_logger = loggerFactory.CreateLogger<RedmineManager>();
_logger.LogDebug("Initializing RedmineManager with URL: {Url}", config.RedmineUrl);
_redmineConnect = new RedmineConnect(httpClient, loggerFactory.CreateLogger<RedmineConnect>(), config);
_projectCache = new(config.ProjectCacheDuration, loggerFactory.CreateLogger<RedmineCache<SimpleProject>>());
_issueCache = new(config.IssueCacheDuration, loggerFactory.CreateLogger<RedmineCache<SimpleIssue>>());
_logger.LogDebug("RedmineManager initialized");
}
/// <summary>
/// Checks if the Redmine instance is available by verifying the API key.
/// </summary>
/// <returns>True if available, false otherwise.</returns>
public async Task<bool> IsRedmineAvailable()
{
_logger.LogDebug("Checking if Redmine is available");
try
{
using (await _lock.LockAsync())
{
await _redmineConnect.VerifyApiKey();
}
_logger.LogDebug("Redmine is available");
return true;
}
catch (Exception ex)
{
_logger.LogDebug("Redmine not available: {Message}", ex.Message);
return false;
}
}
/// <summary>
/// Logs time for a specific issue.
/// </summary>
/// <param name="issueId">The issue ID.</param>
/// <param name="hours">Hours to log.</param>
/// <param name="comments">Comments for the time entry.</param>
/// <param name="date">Date of the time entry.</param>
/// <param name="activityId">Optional activity ID.</param>
public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime date, int? activityId = null)
{
_logger.LogDebug("Logging {Hours} hours for issue {IssueId} on {Date}", hours, issueId, date.ToShortDateString());
using (await _lock.LockAsync())
{
await _redmineConnect.LogTimeAsync(issueId, hours, comments, date, activityId);
}
_logger.LogDebug("Time logged successfully");
}
/// <summary>
/// Closes the specified issue.
/// </summary>
/// <param name="issueId">The issue ID to close.</param>
public async Task CloseIssueAsync(int issueId)
{
_logger.LogDebug("Closing issue {IssueId}", issueId);
using (await _lock.LockAsync())
{
await _redmineConnect.CloseIssueAsync(issueId);
}
_logger.LogDebug("Issue {IssueId} closed", issueId);
}
/// <summary>
/// Creates a new issue in the specified project.
/// </summary>
/// <param name="projectId">The project ID.</param>
/// <param name="trackerId">The tracker ID.</param>
/// <param name="subject">Issue subject.</param>
/// <param name="description">Optional description.</param>
/// <param name="estimatedHours">Optional estimated hours.</param>
/// <param name="priorityId">Optional priority ID.</param>
/// <param name="parentIssueId">Optional parent issue ID.</param>
/// <returns>The created issue ID.</returns>
public async Task<int> CreateIssueAsync(string projectId, int trackerId, string subject, string? description = null,
double? estimatedHours = null, int? priorityId = 9, int? parentIssueId = null)
{
_logger.LogDebug("Creating issue in project {ProjectId} with subject '{Subject}'", projectId, subject);
using (await _lock.LockAsync())
{
var issueId = await _redmineConnect.CreateIssueAsync(projectId, trackerId, subject, description,
estimatedHours, priorityId, parentIssueId);
_logger.LogDebug("Issue created with ID {IssueId}", issueId);
return issueId;
}
}
/// <summary>
/// Retrieves the list of projects, using cache if valid.
/// </summary>
/// <param name="limit">Maximum number of projects to fetch per request.</param>
/// <param name="progress">Optional progress reporter.</param>
/// <returns>List of simple projects.</returns>
public async Task<List<SimpleProject>> GetProjectsAsync(int limit = 100, IProgress<(int, int)>? progress = null)
{
_logger.LogDebug("Getting projects");
using (await _lock.LockAsync())
{
List<SimpleProject> projects = [];
if(!_projectCache.IsCacheValid())
{
_logger.LogDebug("Cache invalid, refreshing");
_projectCache.RefreshCache([..(await _redmineConnect.GetProjectsAsync(limit, progress))]);
}
else
{
_logger.LogDebug("Using cached projects");
}
projects = _projectCache.GetItems();
_logger.LogDebug("Retrieved {Count} projects", projects.Count);
return projects;
}
}
/// <summary>
/// Retrieves trackers for the specified project.
/// </summary>
/// <param name="projectId">The project ID.</param>
/// <param name="token">Optional cancellation token.</param>
/// <returns>List of simple trackers.</returns>
public async Task<List<SimpleTracker>> GetTrackersAsync(string projectId, CancellationToken? token = null)
{
_logger.LogDebug("Getting trackers for project {ProjectId}", projectId);
try
{
using (await _lock.LockAsync())
{
var trackers = await _redmineConnect.GetTrackersAsync(projectId, token);
_logger.LogDebug("Retrieved {Count} trackers", trackers.Count);
return trackers;
}
}
catch (OperationCanceledException)
{
_logger.LogDebug("GetTrackersAsync cancelled");
throw;
}
}
/// <summary>
/// Retrieves current issues with spent time.
/// </summary>
/// <param name="progress">Optional progress reporter.</param>
/// <returns>List of issue items.</returns>
public async Task<List<IssueItem>> GetCurrentIssuesAsync(IProgress<(int, int)>? progress = null)
{
_logger.LogDebug("Getting current issues");
using (await _lock.LockAsync())
{
List<SimpleIssue> simpleIssues;
if (!_issueCache.IsCacheValid())
{
_logger.LogDebug("Issue cache invalid, refreshing");
simpleIssues = [.. (await _redmineConnect.GetMyIssuesAsync())];
_issueCache.RefreshCache(simpleIssues);
}
else
{
_logger.LogDebug("Using cached issues");
simpleIssues = _issueCache.GetItems();
}
var issues = await _redmineConnect.GetSpentTimeForIssuesAsync(simpleIssues, progress);
_logger.LogDebug("Retrieved {Count} issues", issues.Count);
return issues;
}
}
/// <summary>
/// Retrieves logged hours for the specified date range.
/// </summary>
/// <param name="startDate">Start date.</param>
/// <param name="endDate">End date.</param>
/// <returns>Total logged hours.</returns>
public async Task<double> GetLoggedHoursAsync(DateTime? startDate = null, DateTime? endDate = null)
{
var start = DateTime.Today;
var end = DateTime.Today;
if (startDate.HasValue)
start = startDate.Value;
if(endDate.HasValue)
end = endDate.Value;
_logger.LogDebug("Getting logged hours from {Start} to {End}", start.ToShortDateString(), end.ToShortDateString());
using (await _lock.LockAsync())
{
var hours = await _redmineConnect.GetTodaysHoursAsync(start, end);
_logger.LogDebug("Retrieved {Hours} hours", hours);
return hours;
}
}
}
}

View File

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

View File

@@ -0,0 +1,95 @@
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
using System.Text.Json.Serialization;
namespace Blueberry.Redmine.Dto
{
public class CustomFieldList
{
public class CustomField
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("customized_type")]
public string CustomizedType { get; set; }
[JsonPropertyName("field_format")]
public string FieldFormat { get; set; }
[JsonPropertyName("regexp")]
public string Regexp { get; set; }
[JsonPropertyName("min_length")]
public int? MinLength { get; set; }
[JsonPropertyName("max_length")]
public int? MaxLength { get; set; }
[JsonPropertyName("is_required")]
public bool IsRequired { get; set; }
[JsonPropertyName("is_filter")]
public bool IsFilter { get; set; }
[JsonPropertyName("searchable")]
public bool Searchable { get; set; }
[JsonPropertyName("multiple")]
public bool Multiple { get; set; }
[JsonPropertyName("default_value")]
public string DefaultValue { get; set; }
[JsonPropertyName("visible")]
public bool Visible { get; set; }
[JsonPropertyName("possible_values")]
public List<PossibleValue> PossibleValues { get; set; }
[JsonPropertyName("trackers")]
public List<Tracker> Trackers { get; set; }
[JsonPropertyName("roles")]
public List<Role> Roles { get; set; }
}
public class PossibleValue
{
[JsonPropertyName("value")]
public string Value { get; set; }
[JsonPropertyName("label")]
public string Label { get; set; }
}
public class Role
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Root
{
[JsonPropertyName("custom_fields")]
public List<CustomField> CustomFields { get; set; }
}
public class Tracker
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.

View File

@@ -0,0 +1,197 @@
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
using System.Text.Json.Serialization;
namespace Blueberry.Redmine.Dto
{
public class DetailedIssue
{
public class AssignedTo
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Author
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class CustomField
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("value")]
public string Value { get; set; }
}
public class Detail
{
[JsonPropertyName("property")]
public string Property { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("old_value")]
public string OldValue { get; set; }
[JsonPropertyName("new_value")]
public string NewValue { get; set; }
}
public class Issue
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("project")]
public Project Project { get; set; }
[JsonPropertyName("tracker")]
public Tracker Tracker { get; set; }
[JsonPropertyName("status")]
public Status Status { get; set; }
[JsonPropertyName("priority")]
public Priority Priority { get; set; }
[JsonPropertyName("author")]
public Author Author { get; set; }
[JsonPropertyName("assigned_to")]
public AssignedTo AssignedTo { get; set; }
[JsonPropertyName("subject")]
public string Subject { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("start_date")]
public string StartDate { get; set; }
[JsonPropertyName("due_date")]
public object DueDate { get; set; }
[JsonPropertyName("done_ratio")]
public int DoneRatio { get; set; }
[JsonPropertyName("is_private")]
public bool IsPrivate { get; set; }
[JsonPropertyName("estimated_hours")]
public object EstimatedHours { get; set; }
[JsonPropertyName("total_estimated_hours")]
public object TotalEstimatedHours { get; set; }
[JsonPropertyName("spent_hours")]
public double SpentHours { get; set; }
[JsonPropertyName("total_spent_hours")]
public double TotalSpentHours { get; set; }
[JsonPropertyName("custom_fields")]
public List<CustomField> CustomFields { get; set; }
[JsonPropertyName("created_on")]
public DateTime CreatedOn { get; set; }
[JsonPropertyName("updated_on")]
public DateTime UpdatedOn { get; set; }
[JsonPropertyName("closed_on")]
public object ClosedOn { get; set; }
[JsonPropertyName("journals")]
public List<Journal> Journals { get; set; }
}
public class Journal
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("user")]
public User User { get; set; }
[JsonPropertyName("notes")]
public string Notes { get; set; }
[JsonPropertyName("created_on")]
public DateTime CreatedOn { get; set; }
[JsonPropertyName("private_notes")]
public bool PrivateNotes { get; set; }
[JsonPropertyName("details")]
public List<Detail> Details { get; set; }
}
public class Priority
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Project
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Root
{
[JsonPropertyName("issue")]
public Issue Issue { get; set; }
}
public class Status
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Tracker
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class User
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.

View File

@@ -0,0 +1,9 @@
namespace Blueberry.Redmine.Dto
{
public interface IResponseList
{
public int TotalCount { get; set; }
public int Offset { get; set; }
public int Limit { get; set; }
}
}

View File

@@ -0,0 +1,183 @@
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
using System.Text.Json.Serialization;
namespace Blueberry.Redmine.Dto
{
public class IssueList
{
public class AssignedTo
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Author
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class CustomField
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("value")]
public string Value { get; set; }
}
public class Issue
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("project")]
public Project Project { get; set; }
public string ProjectName => Project.Name;
[JsonPropertyName("tracker")]
public Tracker Tracker { get; set; }
[JsonPropertyName("status")]
public Status Status { get; set; }
public string StatusName => Status.Name;
[JsonPropertyName("priority")]
public Priority Priority { get; set; }
public string PriorityName => Priority.Name;
[JsonPropertyName("author")]
public Author Author { get; set; }
[JsonPropertyName("assigned_to")]
public AssignedTo AssignedTo { get; set; }
[JsonPropertyName("subject")]
public string Subject { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("start_date")]
public string StartDate { get; set; }
[JsonPropertyName("due_date")]
public string DueDate { get; set; }
[JsonPropertyName("done_ratio")]
public int DoneRatio { get; set; }
[JsonPropertyName("is_private")]
public bool IsPrivate { get; set; }
[JsonPropertyName("estimated_hours")]
public double? EstimatedHours { get; set; }
[JsonPropertyName("custom_fields")]
public List<CustomField> CustomFields { get; set; }
[JsonPropertyName("created_on")]
public DateTime CreatedOn { get; set; }
[JsonPropertyName("updated_on")]
public DateTime UpdatedOn { get; set; }
public string LastUpdate
{
get
{
var span = DateTime.Now - UpdatedOn;
if (span.TotalMinutes < 1) return "épp most";
if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes} perce";
if (span.TotalHours < 24) return $"{(int)span.TotalHours} órája";
if (span.TotalDays < 7) return $"{(int)span.TotalDays} napja";
if (span.TotalDays < 30) return $"{(int)(span.TotalDays / 7)} hete";
if (span.TotalDays < 365) return $"{(int)(span.TotalDays / 30)} hónapja";
return $"{(int)(span.TotalDays / 365)} éve";
}
}
[JsonPropertyName("closed_on")]
public DateTime? ClosedOn { get; set; }
[JsonPropertyName("parent")]
public Parent Parent { get; set; }
}
public class Parent
{
[JsonPropertyName("id")]
public int Id { get; set; }
}
public class Priority
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Project
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class SimpleRoot
{
[JsonPropertyName("issue")]
public Issue Issue { get; set; }
}
public class Root : IResponseList
{
[JsonPropertyName("issues")]
public List<Issue> Issues { get; set; }
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
[JsonPropertyName("offset")]
public int Offset { get; set; }
[JsonPropertyName("limit")]
public int Limit { get; set; }
}
public class Status
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Tracker
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.

View File

@@ -0,0 +1,131 @@
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
using System.Text.Json.Serialization;
namespace Blueberry.Redmine.Dto
{
public class NewIssue
{
public class Author
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class CustomField
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("value")]
public string Value { get; set; }
}
public class Issue
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("project")]
public Project Project { get; set; }
[JsonPropertyName("tracker")]
public Tracker Tracker { get; set; }
[JsonPropertyName("status")]
public Status Status { get; set; }
[JsonPropertyName("priority")]
public Priority Priority { get; set; }
[JsonPropertyName("author")]
public Author Author { get; set; }
[JsonPropertyName("subject")]
public string Subject { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("start_date")]
public string StartDate { get; set; }
[JsonPropertyName("due_date")]
public object DueDate { get; set; }
[JsonPropertyName("done_ratio")]
public int DoneRatio { get; set; }
[JsonPropertyName("is_private")]
public bool IsPrivate { get; set; }
[JsonPropertyName("estimated_hours")]
public double EstimatedHours { get; set; }
[JsonPropertyName("total_estimated_hours")]
public double TotalEstimatedHours { get; set; }
[JsonPropertyName("custom_fields")]
public List<CustomField> CustomFields { get; set; }
[JsonPropertyName("created_on")]
public DateTime CreatedOn { get; set; }
[JsonPropertyName("updated_on")]
public DateTime UpdatedOn { get; set; }
[JsonPropertyName("closed_on")]
public object ClosedOn { get; set; }
}
public class Priority
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Project
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Root
{
[JsonPropertyName("issue")]
public Issue Issue { get; set; }
}
public class Status
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Tracker
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.

View File

@@ -0,0 +1,32 @@
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
using System.Text.Json.Serialization;
namespace Blueberry.Redmine.Dto
{
public class PriorityList
{
public class IssuePriority
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("is_default")]
public bool IsDefault { get; set; }
[JsonPropertyName("active")]
public bool Active { get; set; }
}
public class Root
{
[JsonPropertyName("issue_priorities")]
public List<IssuePriority> IssuePriorities { get; set; }
}
}
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.

View File

@@ -0,0 +1,83 @@
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
using System.Text.Json.Serialization;
namespace Blueberry.Redmine.Dto
{
public class ProjectList
{
public class CustomField
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("value")]
public string Value { get; set; }
}
public class Parent
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Project
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("identifier")]
public string Identifier { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("is_public")]
public bool IsPublic { get; set; }
[JsonPropertyName("inherit_members")]
public bool InheritMembers { get; set; }
[JsonPropertyName("custom_fields")]
public List<CustomField> CustomFields { get; set; }
[JsonPropertyName("created_on")]
public DateTime CreatedOn { get; set; }
[JsonPropertyName("updated_on")]
public DateTime UpdatedOn { get; set; }
[JsonPropertyName("parent")]
public Parent Parent { get; set; }
}
public class Root : IResponseList
{
[JsonPropertyName("projects")]
public List<Project> Projects { get; set; }
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
[JsonPropertyName("offset")]
public int Offset { get; set; }
[JsonPropertyName("limit")]
public int Limit { get; set; }
}
}
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.

View File

@@ -0,0 +1,89 @@
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
using System.Text.Json.Serialization;
namespace Blueberry.Redmine.Dto
{
public class ProjectTrackers
{
public class CustomField
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("value")]
public string Value { get; set; }
}
public class Parent
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Project
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("identifier")]
public string Identifier { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("homepage")]
public string Homepage { get; set; }
[JsonPropertyName("parent")]
public Parent Parent { get; set; }
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("is_public")]
public bool IsPublic { get; set; }
[JsonPropertyName("inherit_members")]
public bool InheritMembers { get; set; }
[JsonPropertyName("custom_fields")]
public List<CustomField> CustomFields { get; set; }
[JsonPropertyName("trackers")]
public List<Tracker> Trackers { get; set; }
[JsonPropertyName("created_on")]
public DateTime CreatedOn { get; set; }
[JsonPropertyName("updated_on")]
public DateTime UpdatedOn { get; set; }
}
public class Root
{
[JsonPropertyName("project")]
public Project Project { get; set; }
}
public class Tracker
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.

View File

@@ -0,0 +1,29 @@
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
using System.Text.Json.Serialization;
namespace Blueberry.Redmine.Dto
{
public class StatusList
{
public class IssueStatus
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("is_closed")]
public bool IsClosed { get; set; }
}
public class Root
{
[JsonPropertyName("issue_statuses")]
public List<IssueStatus> IssueStatuses { get; set; }
}
}
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.

View File

@@ -0,0 +1,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.

View 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.

View 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.

View File

@@ -0,0 +1,107 @@
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
using System.Text.Json.Serialization;
namespace Blueberry.Redmine.Dto
{
public class UserTime
{
public class Activity
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class CustomField
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("value")]
public string Value { get; set; }
}
public class Issue
{
[JsonPropertyName("id")]
public int Id { get; set; }
}
public class Project
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
public class Root : IResponseList
{
[JsonPropertyName("time_entries")]
public List<TimeEntry> TimeEntries { get; set; }
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
[JsonPropertyName("offset")]
public int Offset { get; set; }
[JsonPropertyName("limit")]
public int Limit { get; set; }
}
public class TimeEntry
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("project")]
public Project Project { get; set; }
[JsonPropertyName("issue")]
public Issue Issue { get; set; }
[JsonPropertyName("user")]
public User User { get; set; }
[JsonPropertyName("activity")]
public Activity Activity { get; set; }
[JsonPropertyName("hours")]
public double Hours { get; set; }
[JsonPropertyName("comments")]
public string Comments { get; set; }
[JsonPropertyName("spent_on")]
public string SpentOn { get; set; }
[JsonPropertyName("created_on")]
public DateTime CreatedOn { get; set; }
[JsonPropertyName("updated_on")]
public DateTime UpdatedOn { get; set; }
[JsonPropertyName("custom_fields")]
public List<CustomField> CustomFields { get; set; }
}
public class User
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
}
}
}
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.

View File

@@ -0,0 +1,560 @@
using Blueberry.Redmine.Dto;
using Microsoft.Extensions.Logging;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Runtime.InteropServices;
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 = [];
_logger.LogDebug("Starting paged request to {Endpoint} with limit {Limit}", endpoint, limit);
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;
}
*/
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 sw = new Stopwatch();
sw.Start();
var offset = 0;
List<TReturn> returnList = [];
_logger.LogDebug("Starting paged request to {Endpoint} with limit {Limit}", endpoint.Split('?')[0], limit);
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();
var total = responseList.TotalCount;
returnList.AddRange(itemParser(responseList));
offset += limit;
if (offset >= responseList.TotalCount)
{
_logger.LogDebug(" + Single request retrieved all {TotalItems} items", returnList.Count);
return returnList;
}
var remain = total - offset;
var tasks = new Task[(int)Math.Ceiling((double)remain / limit)];
List<(int id, IEnumerable<TReturn> items)> responses = [];
var preparationTime = sw.ElapsedMilliseconds;
sw.Restart();
for (int i = 0; i < tasks.Length; i++)
{
var id = i;
var o = offset;
tasks[i] = Task.Run(async () =>
{
if (endpoint.Contains('?'))
path = $"{endpoint}&limit={limit}&offset={o}";
else
path = $"{endpoint}?limit={limit}&offset={o}";
var responseList = await SendRequestAsync<TResponse>(HttpMethod.Get, path, token: token)
?? throw new NullReferenceException();
responses.Add((id, itemParser(responseList)));
});
offset += limit;
}
while (tasks.Any(t => !t.IsCompleted))
{
if (progress != null)
{
var current = Math.Min(tasks.Count(x=>x.IsCompletedSuccessfully), tasks.Length);
progress.Report((current, tasks.Length));
}
await Task.Delay(250);
}
await Task.WhenAll(tasks);
await Task.Delay(50);
var executionTime = sw.ElapsedMilliseconds;
sw.Restart();
foreach (var resp in responses.OrderBy(x => x.id))
{
returnList.AddRange(resp.items);
}
_logger.LogDebug(" + Aggregated total of {TotalItems} items from {Count} responses", returnList.Count, responses.Count);
var aggregationTime = sw.ElapsedMilliseconds;
sw.Stop();
// log times
_logger.LogDebug(" + Paging completed in:\n Preparation: {Preparation}ms\n Execution: {Execution}ms\n Aggregation: {Aggregation}ms\n Total: {Total}ms"
, preparationTime, executionTime, aggregationTime, preparationTime + executionTime + aggregationTime);
// log avarege time per task and per item, round them to whole ms
_logger.LogDebug(" + Average time per task: {AvgTask}ms, Average time per item: {AvgItem}ms",
Math.Round((double)(preparationTime + executionTime) / tasks.Length + 1),
Math.Round((double)(executionTime) / returnList.Count));
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)).OrderBy(x => x.FullName)];
}
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);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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);
}
}
}

View File

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

View File

@@ -1,11 +1,11 @@

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

View File

@@ -1,6 +1,8 @@
using BlueMine.Redmine;
using Blueberry;
using Blueberry.Redmine;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.IO;
@@ -23,19 +25,25 @@ namespace BlueMine
.ConfigureAppConfiguration(c => { c.SetBasePath(Path.GetDirectoryName(AppContext.BaseDirectory) ?? throw new NullReferenceException()); })
.ConfigureServices((context, services) =>
{
services.AddSingleton<SettingsManager>();
services.AddSingleton<RedmineSettingsManager>();
services.AddSingleton(sp =>
{
var manager = sp.GetRequiredService<SettingsManager>();
var manager = sp.GetRequiredService<RedmineSettingsManager>();
return manager.Load();
});
//services.AddTransient<RedmineAuthHandler>();
services.AddHttpClient<RedmineManager>(client => client.BaseAddress = new Uri("http://localhost/"));
//services.AddHttpClient("client", client => client.BaseAddress = new Uri("http://localhost/")).RemoveAllLoggers();
services.AddHttpClient("client").RemoveAllLoggers();
services.Configure<LoggerFilterOptions>(opts =>
{
opts.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
});
// .AddHttpMessageHandler<RedmineAuthHandler>();
services.AddSingleton<RedmineManager>();
services.AddSingleton<HoursWindow>();
services.AddSingleton<MainWindow>();
}).Build();
@@ -65,6 +73,11 @@ namespace BlueMine
await _host.StopAsync();
_host.Dispose();
if (await UpdateManager.IsUpdateAvailable())
{
await UpdateManager.WaitUntilDownloadCompleteAndUpdate();
}
}
/// <summary>

View File

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

100
Blueberry/Constants.cs Normal file
View 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
View 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>

View File

@@ -0,0 +1,273 @@
using Blueberry.Redmine;
using Blueberry.Redmine.Dto;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using Wpf.Ui.Controls;
using static System.Runtime.InteropServices.JavaScript.JSType;
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 = [];
private readonly ILogger _logger;
public ObservableCollection<DisplayHours> Hours { get; set; } = [];
public HoursWindow(RedmineManager manager, RedmineConfig config, ILogger<HoursWindow> logger)
{
InitializeComponent();
DataContext = this;
_manager = manager;
_config = config;
_logger = logger;
}
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 = _users.First(x=>x.Id == current.Id);
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;
var firstDate = userCalendar.SelectedDates.First();
var lastDate = userCalendar.SelectedDates.Last();
var newTicketTask = _manager.GetIssuesAsync(user.Id, createdFrom: selectedDates.First(), createdTo: selectedDates.Last());
var closedTicketTask = _manager.GetIssuesAsync(user.Id, isOpen: false, updatedFrom: selectedDates.First(), updatedTo: selectedDates.Last());
var currentTicketTask = _manager.GetUserOpenIssuesAsync(user.Id);
var h = await _manager.GetTimeForUserAsync(user.Id, firstDate, lastDate);
var list = new List<string>();
foreach (var item in h)
if (await GetIssueNameAsync(item.Issue.Id, download: false) == "")
list.Add(item.Issue.Id.ToString());
if (list.Count > 0)
{
list = [.. list.Distinct()];
_logger.LogDebug("Downloading issue names for {count} issues", list.Count);
var ts = new Task[list.Count];
for (int y = 0; y < list.Count; y++)
{
string? id = list[y];
ts[y] = GetIssueNameAsync(int.Parse(id));
}
await Task.WhenAll(ts);
}
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 = DateTime.Parse(item.SpentOn),
CreatedOn = item.CreatedOn,
Hours = item.Hours.ToString(),
Comments = item.Comments,
IsSeparator = false
};
hours.Add(dh);
}
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)
{
var ttl = 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: " + ttl + " óra"
};
orderedHours.Insert(i, dh);
previousDate = orderedHours[i + 1].Date.Date;
}
}
hoursProgress.Visibility = Visibility.Hidden;
foreach (var item in orderedHours)
Hours.Add(item);
await Task.WhenAll(newTicketTask, closedTicketTask, currentTicketTask);
var newTickets = newTicketTask.Result;
var closedTickets = closedTicketTask.Result;
var currentTickets = currentTicketTask.Result;
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";
}
private async Task<string> GetIssueNameAsync(int issueId, bool download = true)
{
try
{
if (_issueNames.ContainsKey(issueId))
return _issueNames[issueId];
if(download)
{
var name = (await _manager.GetSimpleIssueAsync(issueId)).Subject;
_issueNames.TryAdd(issueId, name);
return name;
} else
{
return "";
}
}
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
View 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>

View 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);
}
}
}

View File

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

View File

@@ -1,11 +1,13 @@
using BlueMine.Redmine;
using Blueberry;
using Blueberry.Redmine;
using Blueberry.Redmine.Dto;
using Microsoft.Extensions.Logging;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Input;
using Wpf.Ui.Controls;
using static BlueMine.Redmine.RedmineDto;
namespace BlueMine
{
@@ -15,30 +17,63 @@ namespace BlueMine
public partial class MainWindow : FluentWindow
{
private readonly RedmineManager _manager;
private readonly SettingsManager _settings;
private readonly RedmineSettingsManager _settings;
private readonly RedmineConfig _config;
private List<IssueItem> _issues = [];
public ObservableCollection<IssueItem> IssuesList { get; set; } = [];
private List<IssueList.Issue> _issues = [];
private readonly ILoggerFactory _loggerFactory;
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, ILoggerFactory loggerFactory)
{
_settings = settings;
_config = config;
_manager = manager;
InitializeComponent();
DataContext = this;
_loggerFactory = loggerFactory;
}
private async void WindowLoaded(object sender, RoutedEventArgs e)
{
apiUrlTextBox.Text = _config.RedmineUrl;
apiPasswordBox.PlaceholderText = new string('●', _config.ApiKey.Length);
mainCalendar.SelectedDate = DateTime.Today;
versionTextBlock.Text = UpdateManager.CurrentVersion;
if(await TestConnection())
{
await LoadIssues();
await GetHours();
Task loadIssuesTask = LoadIssues();
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)
@@ -51,6 +86,7 @@ namespace BlueMine
var progress = new Progress<(int current, int total)>();
progress.ProgressChanged += (s, args) =>
{
progressRing.Visibility = Visibility.Visible;
int current = args.current;
int total = args.total;
statusTextBlock.Text = $"{message}: {current} / {total}";
@@ -76,7 +112,7 @@ namespace BlueMine
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
{
@@ -105,15 +141,6 @@ namespace BlueMine
}
}
private void DataGridSelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
/*if (issuesDataGrid.SelectedItem is IssueItem item)
{
IssueNumberTextBox.Text = item.IssueNumber.ToString();
}*/
}
private void SearchTextBoxTextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
{
FilterIssues();
@@ -121,8 +148,10 @@ namespace BlueMine
private async void RefreshButtonClick(object sender, RoutedEventArgs e)
{
await LoadIssues();
await GetHours();
Task loadIssuesTask = LoadIssues();
Task getHoursTask = GetHours();
await Task.WhenAll(loadIssuesTask, getHoursTask);
}
private void BrowserButtonClick(object sender, RoutedEventArgs e)
@@ -130,7 +159,7 @@ namespace BlueMine
var issueNum = IssueNumberTextBox.Text;
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
{
@@ -143,43 +172,23 @@ namespace BlueMine
private async void CloseButtonClick(object sender, RoutedEventArgs e)
{
var issueNum = IssueNumberTextBox.Text;
if (int.TryParse(issueNum, out var issueId))
{
try
{
await _manager.CloseIssueAsync(issueId);
await new Wpf.Ui.Controls.MessageBox
{
Title = "Sikeres művelet",
Content = $"A(z) {issueId} számú jegy lezárva.",
}.ShowDialogAsync();
}
catch (Exception)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Hiba",
Content = $"A(z) {issueId} számú jegy lezárása sikertelen.",
}.ShowDialogAsync();
}
}
StatusList.Clear();
var s = await _manager.GetStatusesAsync();
foreach (var status in s)
StatusList.Add(status);
await new Wpf.Ui.Controls.MessageBox
{
Title = "Hiba",
Content = "Érvénytelen jegyszám.",
}.ShowDialogAsync();
statusFlyout.IsOpen = true;
}
private async void FixButtonClick(object sender, RoutedEventArgs e)
{
var progress = UpdateProgress("Idők javítása:");
progressRing.Visibility = Visibility.Visible;
var i = 0;
foreach (var date in mainCalendar.SelectedDates)
{
var hours = 8 - await _manager.GetLoggedHoursAsync(date, date);
var hours = 8 - await _manager.GetCurrentUserTimeAsync(date, date);
if (hours <= 0)
continue;
var message = Constants.GenericMessages[Random.Shared.Next(Constants.GenericMessages.Length)];
@@ -189,6 +198,7 @@ namespace BlueMine
i++;
}
progressBar.Value = 0;
progressRing.Visibility = Visibility.Hidden;
await GetHours();
statusTextBlock.Text = "Idők javítva";
@@ -196,6 +206,16 @@ namespace BlueMine
private async void sendButton_Click(object sender, RoutedEventArgs e)
{
if(mainCalendar.SelectedDates.Count == 0)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Nap hiba",
Content = "Nincs kijelölve nap."
}.ShowDialogAsync();
return;
}
if(int.TryParse(IssueNumberTextBox.Text, out var issueId)
&& double.TryParse(HoursTextBox.Text, out var hours))
{
@@ -209,16 +229,17 @@ namespace BlueMine
return;
}
var total = mainCalendar.SelectedDates.Count;
var progress = UpdateProgress("Idők beköldése:");
var progress = UpdateProgress("Idők beküldése:");
progressRing.Visibility = Visibility.Visible;
for (int i = 0; i < total; i++)
{
await _manager.LogTimeAsync(issueId, hours, MessageTextBox.Text, mainCalendar.SelectedDates[i]);
progress.Report((i, total));
progress.Report((i + 1, total));
}
progressBar.Value = 0;
statusTextBlock.Text = "Idők beküldve";
await GetHours();
progressBar.Value = 0;
progressRing.Visibility = Visibility.Hidden;
statusTextBlock.Text = "Idők beküldve";
} else
{
await new Wpf.Ui.Controls.MessageBox
@@ -233,11 +254,131 @@ namespace BlueMine
private void ListView_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
var lv = sender as ListView;
if(lv != null && lv.SelectedItem is IssueItem item)
if(lv != null && lv.SelectedItem is IssueList.Issue item)
{
IssueNumberTextBox.Text = item.IssueNumber.ToString();
IssueNumberTextBox.Text = item.Id.ToString();
}
}
private async void statusSaveButton_Click(object sender, RoutedEventArgs e)
{
var issueNum = IssueNumberTextBox.Text;
if (int.TryParse(issueNum, out var issueId))
{
try
{
var status = statusComboBox.SelectedItem as StatusList.IssueStatus;
if (status == null)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Érvénytelen státusz",
Content = "Státusz kiválasztása sikertelen."
}.ShowDialogAsync();
return;
}
await _manager.SetIssueStatusAsync(issueId, status.Id);
var oldIssue = IssuesList.First(x=>x.Id == issueId);
var newIssue = await _manager.GetSimpleIssueAsync(issueId);
var index = IssuesList.IndexOf(oldIssue);
IssuesList.Insert(index, newIssue);
IssuesList.Remove(oldIssue);
await new Wpf.Ui.Controls.MessageBox
{
Title = "Sikeres művelet",
Content = $"A(z) {issueId} számú jegy új státusza: {status.Name}.",
}.ShowDialogAsync();
}
catch (Exception)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Hiba",
Content = $"A(z) {issueId} számú jegy módosítása sikertelen.",
}.ShowDialogAsync();
}
}
else
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Hiba",
Content = "Érvénytelen jegyszám.",
}.ShowDialogAsync();
}
}
private async void searchTextBox_KeyUp(object sender, KeyEventArgs e)
{
if(e.Key == Key.Enter && searchTextBox.Text.Length > 0)
{
if (int.TryParse(searchTextBox.Text, out var issueId))
{
try
{
statusTextBlock.Text = "Jegy keresése...";
progressRing.Visibility = Visibility.Visible;
var issue = await _manager.GetSimpleIssueAsync(issueId);
IssuesList.Clear();
IssuesList.Add(issue);
statusTextBlock.Text = "Jegy betöltve";
progressRing.Visibility = Visibility.Hidden;
} catch (Exception) {
statusTextBlock.Text = "Jegy nem található";
progressRing.Visibility = Visibility.Hidden;
}
}
}
}
private async void openTicketButton_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement button && button.DataContext is IssueList.Issue item)
{
// 2. Access the property directly from your model
var issueId = item.Id;
try
{
statusTextBlock.Text = "Jegy betöltése...";
progressRing.Visibility = Visibility.Visible;
var issue = await _manager.GetIssueAsync(issueId);
statusTextBlock.Text = "Jegy betöltve";
progressRing.Visibility = Visibility.Hidden;
var issueWindow = new IssueWindow(issue, _manager, _config);
issueWindow.Show();
} catch (Exception)
{
statusTextBlock.Text = "Jegy betöltés sikertelen";
progressRing.Visibility = Visibility.Hidden;
}
}
}
private async void trackerButton_Click(object sender, RoutedEventArgs e)
{
if (int.TryParse(IssueNumberTextBox.Text, out var issueId))
await OpenTimeTracker(issueId);
}
private async void hoursButton_Click(object sender, RoutedEventArgs e)
{
var hoursWindow = new HoursWindow(_manager, _config, _loggerFactory.CreateLogger<HoursWindow>());
hoursWindow.Show();
}
private void updateButton_Click(object sender, RoutedEventArgs e)
{
UpdateManager_DownloadCompleted();
}
}
public partial class MainWindow : FluentWindow
@@ -295,22 +436,43 @@ namespace BlueMine
public async Task GetHours()
{
var today = await _manager.GetLoggedHoursAsync(DateTime.Today, DateTime.Today);
var yesterday = await _manager.GetLoggedHoursAsync(DateTime.Today.AddDays(-1), DateTime.Today.AddDays(-1));
todayProgressRing.Visibility =
yesterdayProgressRing.Visibility =
monthProgressRing.Visibility = Visibility.Visible;
var today = await _manager.GetCurrentUserTimeTodayAsync();
var yesterday = await _manager.GetCurrentUserTimeYesterdayAsync();
var thisMonth = await _manager.GetCurrentUserTimeThisMonthAsync();
var m = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
var thisMonth = await _manager.GetLoggedHoursAsync(m, m.AddMonths(1).AddDays(-1));
int workingDays = 0;
DateTime currentDate = DateTime.Today;
for (int day = 1; day <= currentDate.Day; day++)
{
var dateToCheck = new DateTime(currentDate.Year, currentDate.Month, day);
if (dateToCheck.DayOfWeek != DayOfWeek.Saturday &&
dateToCheck.DayOfWeek != DayOfWeek.Sunday)
{
workingDays++;
}
}
var avgHours = Math.Round(thisMonth/workingDays, 2);
todayTimeLabel.Text = today.ToString();
yesterdayTimeLabel.Text = yesterday.ToString();
monthTimeLabel.Text = thisMonth.ToString();
averageTimeLabel.Text = avgHours.ToString();
todayProgressRing.Visibility =
yesterdayProgressRing.Visibility =
monthProgressRing.Visibility = Visibility.Hidden;
}
public void FilterIssues()
{
var list = string.IsNullOrWhiteSpace(searchTextBox.Text)
? _issues
: _issues.Where(issue => issue.IssueName.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase)
|| issue.IssueNumber.ToString().Contains(searchTextBox.Text)
: _issues.Where(issue => issue.Subject.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase)
|| issue.Id.ToString().Contains(searchTextBox.Text)
|| issue.ProjectName.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase));
IssuesList.Clear();
foreach (var item in list)
@@ -321,9 +483,14 @@ namespace BlueMine
public async Task LoadIssues()
{
_issues.Clear();
_issues.AddRange(Constants.StaticTickets);
_issues.AddRange(await _manager.GetCurrentIssuesAsync(UpdateProgress("Jegyek letöltése:")));
statusTextBlock.Text = "Jegyek letöltése...";
progressRing.Visibility = Visibility.Visible;
foreach (var issueId in Constants.StaticTickets)
_issues.Add(await _manager.GetSimpleIssueAsync(issueId));
_issues.AddRange(await _manager.GetCurrentUserOpenIssuesAsync(progress: UpdateProgress("Jegyek letöltése:")));
progressBar.Value = 0;
progressRing.Visibility = Visibility.Hidden;
FilterIssues();
statusTextBlock.Text = "Jegyek letöltve";
}
@@ -359,19 +526,49 @@ namespace BlueMine
}
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();
apiButton.Appearance = Wpf.Ui.Controls.ControlAppearance.Primary;
return false;
}
else
try
{
// Creates a token that cancels automatically
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
// Pass the token. If it hangs, this throws OperationCanceledException
if (await _manager.IsRedmineAvailable(cts.Token))
{
EnableUi();
apiButton.Appearance = Wpf.Ui.Controls.ControlAppearance.Secondary;
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();
}
}
}

View File

@@ -0,0 +1,80 @@
using System.Windows.Media;
namespace Blueberry
{
public static class StringToColorConverter
{
public static SolidColorBrush GetColorFromName(string name)
{
// 1. Get a deterministic hash from the string
int hash = GetStableHashCode(name);
// 2. Generate HSL values based on the hash
// Hue: 0 to 360 (The entire color wheel)
double hue = Math.Abs(hash % 360);
// Saturation: 25 to 100
// We use a bit-shift or different modulo to ensure S and L aren't identical to Hue
double saturation = 25 + (Math.Abs((hash / 2) % 76)); // 0 to 75 + 25 = 25-100
// Lightness: 25 to 75
double lightness = 25 + (Math.Abs((hash / 3) % 51)); // 0 to 50 + 25 = 25-75
// 3. Convert HSL to RGB
Color color = ColorFromHSL(hue, saturation / 100.0, lightness / 100.0);
return new SolidColorBrush(color);
}
// Helper: Standard string.GetHashCode() can vary by platform/version.
// This simple loop ensures "Alice" is always the same color on every machine.
private static int GetStableHashCode(string str)
{
unchecked
{
int hash = 23;
foreach (char c in str)
{
hash = hash * 31 + c;
}
return hash;
}
}
// Helper: HSL to RGB Math
private static Color ColorFromHSL(double h, double s, double l)
{
double r = 0, g = 0, b = 0;
if (s == 0)
{
r = g = b = l; // Achromatic (Grey)
}
else
{
double q = l < 0.5 ? l * (1 + s) : l + s - l * s;
double p = 2 * l - q;
r = HueToRGB(p, q, h / 360.0 + 1.0 / 3.0);
g = HueToRGB(p, q, h / 360.0);
b = HueToRGB(p, q, h / 360.0 - 1.0 / 3.0);
}
return Color.FromRgb(
(byte)(r * 255),
(byte)(g * 255),
(byte)(b * 255));
}
private static double HueToRGB(double p, double q, double t)
{
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
if (t < 1.0 / 2.0) return q;
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
return p;
}
}
}

View File

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

View File

@@ -0,0 +1,108 @@
using Blueberry.Redmine;
using Blueberry.Redmine.Dto;
using System.Timers;
using System.Windows.Input;
using Wpf.Ui.Controls;
namespace Blueberry
{
/// <summary>
/// Interaction logic for TimeTrackerWindow.xaml
/// </summary>
public partial class TimeTrackerWindow : FluentWindow
{
private readonly RedmineConfig _config;
private readonly RedmineManager _manager;
private readonly IssueList.Issue _issue;
public string CurrentIssue => _issue.Subject;
private readonly System.Timers.Timer _timer;
private TimeSpan _elapsedTime;
public TimeTrackerWindow(RedmineConfig config, RedmineManager manager, IssueList.Issue issue)
{
DataContext = this;
_config = config;
_manager = manager;
_issue = issue;
_timer = new System.Timers.Timer(new TimeSpan(0, 0, 1));
_timer.Elapsed += TimerElapsed;
_elapsedTime = new TimeSpan(0);
InitializeComponent();
}
private void FluentWindow_Loaded(object sender, System.Windows.RoutedEventArgs e)
{
issueTextBlock.Text = "#" + _issue.Id;
}
private void TimerElapsed(object? sender, ElapsedEventArgs e)
{
_elapsedTime = _elapsedTime.Add(new(0, 0, 1));
Dispatcher.Invoke(new Action(() =>
{
timeTextBlock.Text = _elapsedTime.ToString(@"hh\:mm\:ss");
}));
}
private void playPauseButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
if(_timer.Enabled)
{
_timer.Stop();
playPauseIcon.Symbol = SymbolRegular.Play24;
}
else
{
_timer.Start();
playPauseIcon.Symbol = SymbolRegular.Pause24;
}
}
private void doneButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
_timer.Stop();
double hour = GetCurrentHours();
resultHourTextBlock.Text = $"{hour}";
resultFlyout.IsOpen = true;
}
private double GetCurrentHours()
{
var hour = (_elapsedTime.TotalMinutes + 10) / 15;
hour = Math.Ceiling(hour / 15.0) * 15.0;
hour /= 60.0;
return hour;
}
private void FluentWindow_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
DragMove();
}
private void cancelButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
Close();
}
private async void resultSaveButton_Click(object sender, System.Windows.RoutedEventArgs e)
{
var hours = GetCurrentHours();
var result = await new MessageBox
{
Title = "Ellenőrzés",
Content = $"Jegy: {_issue.Id}\nÓra: {hours}\nÜzenet: {resultTextBox.Text}\n\nBiztos, hogy beküldöd?",
PrimaryButtonText = "Igen",
SecondaryButtonText = "Nem",
IsCloseButtonEnabled = false,
}.ShowDialogAsync();
if (result == MessageBoxResult.Primary)
{
await _manager.LogTimeAsync(_issue.Id, hours, resultTextBox.Text, DateTime.Today);
await new MessageBox { Title = "Sikeres beküldés", Content = $"A {_issue.Id} jegyre {hours} óra lett felírva." }.ShowDialogAsync();
Close();
}
}
}
}

183
Blueberry/UpdateManager.cs Normal file
View 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.7";
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; }
}
}
}

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

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

View File

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

View File

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

55
README.md Normal file
View File

@@ -0,0 +1,55 @@
# 🫐 Blueberry
![Blueberry](/images/bb.ico)
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

View File

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

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

21
install.ps1 Normal file
View File

@@ -0,0 +1,21 @@
$installDir = "$env:LOCALAPPDATA\Blueberry"
$zipUrl = "https://git.technopunk.space/tomi/Blueberry/releases/download/latest/Blueberry.zip"
$exePath = "$installDir\Blueberry.exe"
if (Test-Path $installDir) { Remove-Item $installDir -Recurse -Force }
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
# 3. Download & Unzip
Write-Host "Downloading Blueberry..."
$zipFile = "$env:TEMP\Blueberry.zip"
Invoke-WebRequest -Uri $zipUrl -OutFile $zipFile
Expand-Archive -Path $zipFile -DestinationPath $installDir -Force
Remove-Item $zipFile
$wsh = New-Object -ComObject WScript.Shell
$shortcut = $wsh.CreateShortcut("$env:USERPROFILE\Desktop\Blueberry.lnk")
$shortcut.TargetPath = $exePath
$shortcut.Save()
# 5. Run it
Start-Process $exePath