initial commit

This commit is contained in:
2025-12-10 10:59:48 +01:00
commit b3605e725f
30 changed files with 2363 additions and 0 deletions

18
BlueMine/App.xaml Normal file
View File

@@ -0,0 +1,18 @@
<Application x:Class="BlueMine.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:BlueMine"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Exit="OnExit"
Startup="OnStartup"
DispatcherUnhandledException="OnDispatcherUnhandledException">
<!--StartupUri="MainWindow.xaml"-->
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark" />
<ui:ControlsDictionary />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

78
BlueMine/App.xaml.cs Normal file
View File

@@ -0,0 +1,78 @@
using BlueMine.Redmine;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.IO;
using System.Windows;
using System.Windows.Threading;
namespace BlueMine
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
private static readonly IHost _host = Host
.CreateDefaultBuilder()
.ConfigureLogging(builder => {
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Debug);
})
.ConfigureAppConfiguration(c => { c.SetBasePath(Path.GetDirectoryName(AppContext.BaseDirectory) ?? throw new NullReferenceException()); })
.ConfigureServices((context, services) =>
{
services.AddSingleton<SettingsManager>();
services.AddSingleton(sp =>
{
var manager = sp.GetRequiredService<SettingsManager>();
return manager.Load();
});
//services.AddTransient<RedmineAuthHandler>();
services.AddHttpClient<RedmineManager>(client => client.BaseAddress = new Uri("http://localhost/"));
// .AddHttpMessageHandler<RedmineAuthHandler>();
services.AddSingleton<RedmineManager>();
services.AddSingleton<MainWindow>();
}).Build();
/// <summary>
/// Gets services.
/// </summary>
public static IServiceProvider Services
{
get { return _host.Services; }
}
/// <summary>
/// Occurs when the application is loading.
/// </summary>
private async void OnStartup(object sender, StartupEventArgs e)
{
await _host.StartAsync();
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
/// <summary>
/// Occurs when the application is closing.
/// </summary>
private async void OnExit(object sender, ExitEventArgs e)
{
await _host.StopAsync();
_host.Dispose();
}
/// <summary>
/// Occurs when an exception is thrown by an application but not handled.
/// </summary>
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
// For more info see https://docs.microsoft.com/en-us/dotnet/api/system.windows.application.dispatcherunhandledexception?view=windowsdesktop-6.0
}
}
}

10
BlueMine/AssemblyInfo.cs Normal file
View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

43
BlueMine/BlueMine.csproj Normal file
View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<StartupObject>BlueMine.App</StartupObject>
<ApplicationIcon>bb.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<None Remove="bb.ico" />
<None Remove="Resources\Inter.ttf" />
</ItemGroup>
<ItemGroup>
<Content Include="bb.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Resources\Inter.ttf">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
<PackageReference Include="WPF-UI" Version="4.1.0" />
</ItemGroup>
<ItemGroup>
<Content Include="Resources\Roboto.ttf">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Resources\Zalando.ttf">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

49
BlueMine/Constants.cs Normal file
View File

@@ -0,0 +1,49 @@
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ó",
];
}
}

215
BlueMine/MainWindow.xaml Normal file
View File

@@ -0,0 +1,215 @@
<ui:FluentWindow x:Class="BlueMine.MainWindow"
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:BlueMine"
mc:Ignorable="d"
Icon="/bb.ico"
Title="Blueberry"
Height="720" Width="1280"
MinWidth="650" MinHeight="450"
d:DataContext="{d:DesignInstance local:MainWindow}"
Loaded="WindowLoaded">
<ui:FluentWindow.Resources>
<FontFamily x:Key="Roboto">/Resources/Roboto.ttf</FontFamily>
<FontFamily x:Key="Zalando">/Resources/Zalando.ttf</FontFamily>
<FontFamily x:Key="Inter">/Resources/Inter.ttf</FontFamily>
</ui:FluentWindow.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="1*" MinHeight="275"/>
<RowDefinition Height="1*" MaxHeight="200"/>
<RowDefinition Height="30"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.ColumnSpan="2" Title="Blueberry">
<ui:TitleBar.Icon>
<ui:ImageIcon Source="/bb.ico" />
</ui:TitleBar.Icon>
</ui:TitleBar>
<Grid Grid.Row="1" Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
</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>
</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>
</ui:Card>
<ui:Card Grid.Column="2" Margin="5, 10, 10, 10">
<StackPanel Orientation="Vertical">
<ui:TextBlock Text="Ehavi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="monthTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
</ui:Card>
</Grid>
<Grid x:Name="TicketList" Grid.Row="1" Grid.Column="0" Grid.RowSpan="3">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="1*"/>
<RowDefinition Height="30"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="1*"/>
</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:Card Grid.Row="1" Grid.ColumnSpan="2" Margin="10, 10, 10, 10"
VerticalAlignment="Stretch" HorizontalAlignment="Stretch" VerticalContentAlignment="Stretch" HorizontalContentAlignment="Stretch">
<ui:ListView ItemsSource="{Binding IssuesList}" ScrollViewer.HorizontalScrollBarVisibility="Disabled" Grid.IsSharedSizeScope="True" SelectionChanged="ListView_SelectionChanged">
<ui:ListView.ItemTemplate>
<DataTemplate>
<Grid Margin="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="x"/>
<ColumnDefinition Width="1*"/>
<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}" />
<TextBlock Grid.Column="1" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" FontSize="14"
Text="{Binding IssueName}" TextTrimming="CharacterEllipsis" />
<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="1" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="10"
Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" Margin="20, 0, 10, 0" Text="{Binding LastUpdate}" />
</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>
<Grid x:Name="InputFields" Grid.Row="2" Grid.Column="1" Grid.RowSpan="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="1*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<ui:Flyout Margin="10" Grid.Row="0" Placement="Top" x:Name="flyoutCalendar">
<Calendar x:Name="mainCalendar" Grid.Row="1" Grid.Column="1" Grid.RowSpan="1" Margin="10" SelectionMode="MultipleRange" SelectedDatesChanged="CalendarSelectedDatesChanged"/>
</ui:Flyout>
<Button Grid.Row="0" Grid.Column="1" x:Name="calendarButton" Content="2025-12-09" HorizontalAlignment="Stretch" Margin="10" Click="CalendarButtonClicked" />
<Label Grid.Row="0" Grid.Column="0" Content="Napok:" Margin="10" HorizontalAlignment="Center"/>
<Label Grid.Row="1" Grid.Column="0" Content="Jegy:" Margin="10" HorizontalAlignment="Center"/>
<Label Grid.Row="2" Grid.Column="0" Content="Óra:" Margin="10" HorizontalAlignment="Center"/>
<Label Grid.Row="3" Grid.Column="0" Content="Üzenet:" Margin="10" HorizontalAlignment="Center"/>
<ui:TextBox Grid.Row="1" Grid.Column="1" x:Name="IssueNumberTextBox" PlaceholderText="65432" Margin="10" Padding="8" PreviewTextInput="NumValidation" DataObject.Pasting="NumPasting"/>
<ui:TextBox Grid.Row="2" Grid.Column="1" x:Name="HoursTextBox" PlaceholderText="0.25" Margin="10" Padding="8" PreviewTextInput="FracValidation" DataObject.Pasting="FracPasting"/>
<ui:TextBox Grid.Row="3" Grid.Column="1" x:Name="MessageTextBox" PlaceholderText="Munka..." VerticalAlignment="Stretch" Margin="10" AcceptsReturn="True" TextWrapping="Wrap" Padding="8"/>
<ui:Button x:Name="sendButton" Appearance="Primary" Grid.Column="1" Grid.ColumnSpan="1" Grid.Row="4" Content="Send"
Margin="10" Padding="10" HorizontalAlignment="Stretch" Click="sendButton_Click">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Send24"/>
</ui:Button.Icon>
</ui:Button>
</Grid>
<Grid x:Name="MenuBlocks" Grid.Row="3" Grid.Column="2" Grid.ColumnSpan="3" Margin="10">
<Grid.ColumnDefinitions>
<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" >
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Checkmark24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="browserButton" Grid.Row="0" Grid.Column="1" Margin="5, 0, 5, 5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Böngésző" Click="BrowserButtonClick" >
<ui:Button.Icon>
<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"
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">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Key24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="refreshButton" Grid.Row="1" Grid.Column="1" Margin="5, 5, 5, 0" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Frissítés" Click="RefreshButtonClick">
<ui:Button.Icon>
<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"
VerticalAlignment="Stretch" Content="Fix" Click="FixButtonClick">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Wrench24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Flyout x:Name="apiFlyout" Grid.Row="1" Grid.Column="0">
<StackPanel Orientation="Vertical" Margin="10" Width="250">
<ui:Button x:Name="apiLinkButton" Content="API kulcs link" HorizontalAlignment="Stretch" Margin="10" Padding="10" Click="apiLinkButton_Click" />
<Label Content="API URL:" Margin="10" HorizontalAlignment="Stretch"/>
<ui:TextBox x:Name="apiUrlTextBox" HorizontalAlignment="Stretch" Margin="10" PlaceholderText="https://redmine.example.com" />
<Label Content="API kulcs:" Margin="10" HorizontalAlignment="Stretch"/>
<ui:PasswordBox x:Name="apiPasswordBox" HorizontalAlignment="Stretch" Margin="10" PasswordChar="●" PlaceholderText="" />
<ui:Button x:Name="apiSaveButton" Content="Csatlakozás" HorizontalAlignment="Stretch" Margin="10" Padding="10" Click="apiSaveButton_Click" />
</StackPanel>
</ui:Flyout>
</Grid>
<ui:TextBlock x:Name="statusTextBlock" Grid.Row="4" Grid.ColumnSpan="6" FontSize="8" Text="Staus: OK" Margin="10" />
</Grid>
</ui:FluentWindow>

377
BlueMine/MainWindow.xaml.cs Normal file
View File

@@ -0,0 +1,377 @@
using BlueMine.Redmine;
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
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : FluentWindow
{
private readonly RedmineManager _manager;
private readonly SettingsManager _settings;
private readonly RedmineConfig _config;
private List<IssueItem> _issues = [];
public ObservableCollection<IssueItem> IssuesList { get; set; } = [];
public MainWindow(RedmineManager manager, SettingsManager settings, RedmineConfig config)
{
_settings = settings;
_config = config;
_manager = manager;
InitializeComponent();
DataContext = this;
}
private async void WindowLoaded(object sender, RoutedEventArgs e)
{
apiUrlTextBox.Text = _config.RedmineUrl;
apiPasswordBox.PlaceholderText = new string('●', _config.ApiKey.Length);
if(await TestConnection())
{
await LoadIssues();
await GetHours();
}
}
private void CalendarButtonClicked(object sender, RoutedEventArgs e)
{
flyoutCalendar.IsOpen = true;
}
private IProgress<(int, int)> UpdateProgress(string message)
{
var progress = new Progress<(int current, int total)>();
progress.ProgressChanged += (s, args) =>
{
int current = args.current;
int total = args.total;
statusTextBlock.Text = $"{message}: {current} / {total}";
progressBar.Value = (double)current / total * 100;
};
return progress;
}
private void ApiButtonClicked(object sender, RoutedEventArgs e)
{
apiFlyout.IsOpen = true;
}
private void CalendarSelectedDatesChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
if(mainCalendar.SelectedDates.Count == 1)
calendarButton.Content = mainCalendar.SelectedDate?.ToString("yyyy-MM-dd");
else if(mainCalendar.SelectedDates.Count > 1)
calendarButton.Content = $"{mainCalendar.SelectedDates.Count} nap kiválasztva";
else
calendarButton.Content = "Válassz egy napot";
}
private void apiLinkButton_Click(object sender, RoutedEventArgs e)
{
string url = "https://support.onliveit.eu:444/redmine/my/account";
var psi = new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
};
Process.Start(psi);
}
private async void apiSaveButton_Click(object sender, RoutedEventArgs e)
{
_config.RedmineUrl = apiUrlTextBox.Text;
_config.ApiKey = apiPasswordBox.Password;
apiFlyout.IsOpen = false;
if(await TestConnection())
{
_settings.Save(_config);
statusTextBlock.Text = "Beállítások mentve és kapcsolódva";
await LoadIssues();
await GetHours();
}
else
{
_settings.Save(_config);
statusTextBlock.Text = "Beállítások mentve, de a Redmine nem elérhető";
}
}
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();
}
private async void RefreshButtonClick(object sender, RoutedEventArgs e)
{
await LoadIssues();
await GetHours();
}
private void BrowserButtonClick(object sender, RoutedEventArgs e)
{
var issueNum = IssueNumberTextBox.Text;
if (int.TryParse(issueNum, out var issueId))
{
string url = $"https://support.onliveit.eu:444/redmine/issues/{issueId}";
var psi = new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
};
Process.Start(psi);
}
}
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();
}
}
await new Wpf.Ui.Controls.MessageBox
{
Title = "Hiba",
Content = "Érvénytelen jegyszám.",
}.ShowDialogAsync();
}
private async void FixButtonClick(object sender, RoutedEventArgs e)
{
var progress = UpdateProgress("Idők javítása:");
var i = 0;
foreach (var date in mainCalendar.SelectedDates)
{
var hours = 8 - await _manager.GetLoggedHoursAsync(date, date);
if (hours <= 0)
continue;
var message = Constants.GenericMessages[Random.Shared.Next(Constants.GenericMessages.Length)];
var id = 801;
await _manager.LogTimeAsync(id, hours, message, date);
progress.Report((i, mainCalendar.SelectedDates.Count));
i++;
}
progressBar.Value = 0;
await GetHours();
statusTextBlock.Text = "Idők javítva";
}
private async void sendButton_Click(object sender, RoutedEventArgs e)
{
if(int.TryParse(IssueNumberTextBox.Text, out var issueId)
&& double.TryParse(HoursTextBox.Text, out var hours))
{
if (hours * 4 != Math.Floor(hours * 4))
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Idő formátum hiba",
Content = "Az idő csak negyedórákra bontható le."
}.ShowDialogAsync();
return;
}
var total = mainCalendar.SelectedDates.Count;
var progress = UpdateProgress("Idők beköldése:");
for (int i = 0; i < total; i++)
{
await _manager.LogTimeAsync(issueId, hours, MessageTextBox.Text, mainCalendar.SelectedDates[i]);
progress.Report((i, total));
}
progressBar.Value = 0;
statusTextBlock.Text = "Idők beküldve";
await GetHours();
} else
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Szám formátum hiba",
Content = "Az idő/jegyszám nem rendes szám."
}.ShowDialogAsync();
return;
}
}
private void ListView_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
var lv = sender as ListView;
if(lv != null && lv.SelectedItem is IssueItem item)
{
IssueNumberTextBox.Text = item.IssueNumber.ToString();
}
}
}
public partial class MainWindow : FluentWindow
{
private static readonly Regex _regexFractions = new Regex(@"^[0-9]*(?:\.[0-9]*)?$");
private static readonly Regex _regexNumbers = new Regex(@"^[0-9]*$");
private void FracValidation(object sender, TextCompositionEventArgs e)
{
var textBox = sender as TextBox;
// Construct what the text WILL be if we allow this input
string fullText = HoursTextBox.Text.Insert(HoursTextBox.CaretIndex, e.Text);
// If the resulting text is not a match, block the input
e.Handled = !_regexFractions.IsMatch(fullText);
}
private void FracPasting(object sender, DataObjectPastingEventArgs e)
{
if (e.DataObject.GetDataPresent(typeof(string)))
{
string text = (string)e.DataObject.GetData(typeof(string));
var textBox = sender as TextBox;
string fullText = HoursTextBox.Text.Insert(HoursTextBox.CaretIndex, text);
if (!_regexFractions.IsMatch(fullText))
e.CancelCommand();
}
else
e.CancelCommand();
}
private void NumValidation(object sender, TextCompositionEventArgs e)
{
var textBox = sender as TextBox;
// Construct what the text WILL be if we allow this input
string fullText = HoursTextBox.Text.Insert(HoursTextBox.CaretIndex, e.Text);
// If the resulting text is not a match, block the input
e.Handled = !_regexNumbers.IsMatch(fullText);
}
private void NumPasting(object sender, DataObjectPastingEventArgs e)
{
if (e.DataObject.GetDataPresent(typeof(string)))
{
string text = (string)e.DataObject.GetData(typeof(string));
var textBox = sender as TextBox;
string fullText = HoursTextBox.Text.Insert(HoursTextBox.CaretIndex, text);
if (!_regexNumbers.IsMatch(fullText))
e.CancelCommand();
}
else
e.CancelCommand();
}
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));
var m = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
var thisMonth = await _manager.GetLoggedHoursAsync(m, m.AddMonths(1).AddDays(-1));
todayTimeLabel.Text = today.ToString();
yesterdayTimeLabel.Text = yesterday.ToString();
monthTimeLabel.Text = thisMonth.ToString();
}
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)
|| issue.ProjectName.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase));
IssuesList.Clear();
foreach (var item in list)
{
IssuesList.Add(item);
}
}
public async Task LoadIssues()
{
_issues.Clear();
_issues.AddRange(Constants.StaticTickets);
_issues.AddRange(await _manager.GetCurrentIssuesAsync(UpdateProgress("Jegyek letöltése:")));
progressBar.Value = 0;
FilterIssues();
statusTextBlock.Text = "Jegyek letöltve";
}
public void DisableUi()
{
searchTextBox.IsEnabled =
//issuesDataGrid.IsEnabled =
IssueNumberTextBox.IsEnabled =
HoursTextBox.IsEnabled =
MessageTextBox.IsEnabled =
calendarButton.IsEnabled =
sendButton.IsEnabled =
closeButton.IsEnabled =
browserButton.IsEnabled =
newButton.IsEnabled =
refreshButton.IsEnabled =
fixButton.IsEnabled = false;
}
public void EnableUi()
{
searchTextBox.IsEnabled =
//issuesDataGrid.IsEnabled =
IssueNumberTextBox.IsEnabled =
HoursTextBox.IsEnabled =
MessageTextBox.IsEnabled =
calendarButton.IsEnabled =
sendButton.IsEnabled =
closeButton.IsEnabled =
browserButton.IsEnabled =
newButton.IsEnabled =
refreshButton.IsEnabled =
fixButton.IsEnabled = true;
}
public async Task<bool> TestConnection()
{
if (!await _manager.IsRedmineAvailable())
{
DisableUi();
apiButton.Appearance = Wpf.Ui.Controls.ControlAppearance.Primary;
return false;
}
else
{
EnableUi();
apiButton.Appearance = Wpf.Ui.Controls.ControlAppearance.Secondary;
statusTextBlock.Text = "Kapcsolódva";
return true;
}
}
}
}

View File

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

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

View File

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

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

@@ -0,0 +1,17 @@
namespace BlueMine.Redmine
{
public class RedmineApiException : Exception
{
public int? StatusCode { get; }
public RedmineApiException(string message, int? statusCode = null) : base(message)
{
StatusCode = statusCode;
}
public RedmineApiException(string message, Exception innerException, int? statusCode = null) : base(message, innerException)
{
StatusCode = statusCode;
}
}
}

View File

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

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

@@ -0,0 +1,12 @@
namespace BlueMine.Redmine
{
public class RedmineConfig
{
public string RedmineUrl { get; set; } = "http://redmine.example.com";
public string ApiKey { get; set; } = "";
public TimeSpan ProjectCacheDuration { get; set; } = TimeSpan.FromMinutes(15);
public TimeSpan IssueCacheDuration { get; set; } = TimeSpan.FromMinutes(5);
public int MaxRetries { get; set; } = 3;
public int ConcurrencyLimit { get; set; } = 10;
}
}

View File

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

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,91 @@
using System.Text;
using BlueMine.Redmine;
using System.IO;
using System.Security.Cryptography; // For encryption
using System.Text.Json;
namespace BlueMine
{
public class SettingsManager
{
// Save to: C:\Users\Username\AppData\Roaming\YourAppName\settings.json
private readonly string _filePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Blueberry",
"settings.json");
public RedmineConfig Load()
{
if (!File.Exists(_filePath))
return new RedmineConfig();
try
{
var json = File.ReadAllText(_filePath);
var config = JsonSerializer.Deserialize<RedmineConfig>(json);
if(config == null)
return new RedmineConfig();
// Decrypt the API Key if it exists
if (!string.IsNullOrEmpty(config.ApiKey))
{
config.ApiKey = Unprotect(config.ApiKey);
}
return config;
}
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
};
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 "";
byte[] clearBytes = Encoding.UTF8.GetBytes(clearText);
byte[] encryptedBytes = ProtectedData.Protect(clearBytes, null, DataProtectionScope.CurrentUser);
return Convert.ToBase64String(encryptedBytes);
}
private string Unprotect(string encryptedText)
{
if (string.IsNullOrEmpty(encryptedText)) return "";
try
{
byte[] encryptedBytes = Convert.FromBase64String(encryptedText);
byte[] clearBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(clearBytes);
}
catch
{
return ""; // Decryption failed (maybe different user/machine)
}
}
}
}

BIN
BlueMine/bb.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB