complete refactor

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

View File

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

View File

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

View File

@@ -1,25 +1,8 @@
using static BlueMine.Redmine.RedmineDto;
namespace BlueMine
namespace Blueberry
{
internal class Constants
{
public static IssueItem[] StaticTickets = [
new IssueItem() {
IssueNumber = 705,
SpentTime = 0,
ProjectName = "OnLiveIT",
IssueName = "Megbeszélés",
IssueDescription = ""
},
new IssueItem() {
IssueNumber = 801,
SpentTime = 0,
ProjectName = "OnLiveIT",
IssueName = "Egyéb",
IssueDescription = ""
},
];
public static int[] StaticTickets = [705, 801];
public static string[] GenericMessages = [
"Config reszelés",

144
Blueberry/IssueWindow.xaml Normal file
View File

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

View File

@@ -0,0 +1,214 @@
using Blueberry.Redmine;
using Blueberry.Redmine.Dto;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Media;
using Wpf.Ui.Controls;
namespace Blueberry
{
/// <summary>
/// Interaction logic for IssueWindow.xaml
/// </summary>
public partial class IssueWindow : FluentWindow
{
private readonly DetailedIssue.Issue _issue;
private readonly RedmineManager _manager;
private readonly RedmineConfig _config;
private readonly List<JournalDisplay> _journalDisplays = [];
public ObservableCollection<JournalDisplay> Journals { get; set; } = [];
public IssueWindow(DetailedIssue.Issue issue, RedmineManager manager, RedmineConfig config)
{
_issue = issue;
_manager = manager;
_config = config;
InitializeComponent();
DataContext = this;
}
private async void FluentWindow_Loaded(object sender, RoutedEventArgs e)
{
iSubjectTextBox.Text = _issue.Subject;
iProjectTextBox.Text = _issue.Project.Name;
iDescriptionTextBox.Text = _issue.Description;
iCreatedTextBox.Text = _issue.CreatedOn.ToString("yyyy-MM-dd");
iUpdatedTextBox.Text = _issue.UpdatedOn.ToString("yyyy-MM-dd");
iSpentTimeTextBox.Text = _issue.SpentHours.ToString();
journalProgressRing.Visibility = Visibility.Visible;
_journalDisplays.AddRange(await ProcessJournal(_issue.Journals));
if(!_journalDisplays.Any(x=>!x.IsData))
detailsToggleSwitch.IsChecked = true;
await LoadJournal();
journalProgressRing.Visibility = Visibility.Hidden;
}
private async Task LoadJournal()
{
var showDetails = detailsToggleSwitch.IsChecked ?? true;
Journals.Clear();
foreach (var j in _journalDisplays)
if ((!showDetails && !j.IsData)
|| showDetails)
Journals.Add(j);
}
private async void detailsToggleSwitch_Checked(object sender, RoutedEventArgs e)
{
await LoadJournal();
}
private void openBrowserButton_Click(object sender, RoutedEventArgs e)
{
string url = $"{_config.RedmineUrl}/issues/{_issue.Id}";
var psi = new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
};
Process.Start(psi);
}
}
public partial class IssueWindow
{
public async Task<List<JournalDisplay>> ProcessJournal(IEnumerable<DetailedIssue.Journal> journals)
{
var js = new List<JournalDisplay>();
foreach (var item in journals)
{
var user = item.User.Name;
var date = item.CreatedOn.ToString("yyyy-MM-dd HH:mm");
var content = item.Notes;
if (!string.IsNullOrWhiteSpace(content))
{
if (item.PrivateNotes)
js.Add(new JournalDisplay
{
User = user,
Date = date,
Content = content,
IsPrivate = true
});
else
js.Add(new JournalDisplay
{
User = user,
Date = date,
Content = content
});
}
foreach (var data in item.Details)
{
try
{
if (data.Property == "attr")
{
switch (data.Name)
{
case "due_date":
var old = data.OldValue ?? "null";
content = $"Due date:\n{old} > {data.NewValue}";
js.Add(new JournalDisplay
{
User = user,
Date = date,
Content = content,
IsData = true
});
break;
case "assigned_to_id":
old = "null";
if (data.OldValue != null)
{
var u = await _manager.GetUserAsync(int.Parse(data.OldValue));
old = u.Firstname + " " + u.Lastname;
}
var newU = await _manager.GetUserAsync(int.Parse(data.NewValue));
content = $"Assigned to:\n{old} > {newU.Firstname} {newU.Lastname}";
js.Add(new JournalDisplay
{
User = user,
Date = date,
Content = content,
IsData = true
});
break;
case "status_id":
old = "null";
if (data.OldValue != null)
{
old = (await _manager.GetStatusesAsync()).Where(x => x.Id == int.Parse(data.OldValue)).First().Name;
}
var newS = (await _manager.GetStatusesAsync()).Where(x => x.Id == int.Parse(data.NewValue)).First().Name;
content = $"Status changed to:\n{old} > {newS}";
js.Add(new JournalDisplay
{
User = user,
Date = date,
Content = content,
IsData = true
});
break;
default:
break;
}
}
if (data.Property == "cf")
{
try
{
if (int.TryParse(data.Name, out var cfId))
{
var cfs = await _manager.GetCustomFieldsAsync();
var cfName = cfs.Where(x => x.Id == cfId).First().Name;
var old = data.OldValue ?? "null";
content = $"{cfName}\n{old} > {data.NewValue}";
js.Add(new JournalDisplay
{
User = user,
Date = date,
Content = content,
IsData = true
});
}
}
catch (Exception) { }
}
} catch (Exception) { }
}
}
return js;
}
public class JournalDisplay
{
public string User { get; set; }
public string Date { get; set; }
public string Content { get; set; }
public bool IsPrivate { get; set; }
public bool IsData { get; set; }
public Brush StatusColor
{
get
{
var resourceKey = IsData ? "TextFillColorTertiaryBrush" : "TextFillColorSecondaryBrush" ;
// Look up the brush from the App resources
return Application.Current.TryFindResource(resourceKey) as Brush;
}
}
public SolidColorBrush NameColor => StringToColorConverter.GetColorFromName(User);
}
}
}

View File

@@ -9,9 +9,9 @@
Icon="/bb.ico"
Title="Blueberry"
Height="720" Width="1280"
MinWidth="650" MinHeight="450"
MinWidth="750" MinHeight="540"
d:DataContext="{d:DesignInstance local:MainWindow}"
Loaded="WindowLoaded">
Loaded="WindowLoaded" WindowStartupLocation="CenterScreen">
<ui:FluentWindow.Resources>
<FontFamily x:Key="Roboto">/Resources/Roboto.ttf</FontFamily>
<FontFamily x:Key="Zalando">/Resources/Zalando.ttf</FontFamily>
@@ -35,29 +35,50 @@
</ui:TitleBar.Icon>
</ui:TitleBar>
<Grid Grid.Row="1" Grid.Column="1">
<Grid x:Name="MetricDisplay" Grid.Row="1" Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<ui:Card Margin="10, 10, 5, 10">
<StackPanel Orientation="Vertical">
<ui:TextBlock Text="Mai órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="todayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="3*" />
</Grid.RowDefinitions>
<ui:ProgressRing x:Name="todayProgressRing" Grid.Row="0" Grid.RowSpan="2" Grid.Column="0" Width="10" Height="10" VerticalAlignment="Center" HorizontalAlignment="Left" IsIndeterminate="True" Visibility="Hidden" />
<ui:TextBlock Grid.Row="0" Grid.Column="0" Text="Mai órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock Grid.Row="1" Grid.Column="0" x:Name="todayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</Grid>
</ui:Card>
<ui:Card Grid.Column="1" Margin="5, 10, 5, 10">
<StackPanel Orientation="Vertical">
<ui:TextBlock Text="Tegnapi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="yesterdayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="3*" />
</Grid.RowDefinitions>
<ui:ProgressRing x:Name="yesterdayProgressRing" Grid.Row="0" Grid.RowSpan="2" Width="10" Height="10" VerticalAlignment="Center" HorizontalAlignment="Left" IsIndeterminate="True" Visibility="Hidden" />
<ui:TextBlock Grid.Row="0" Grid.Column="0" Text="Tegnapi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock Grid.Row="1" Grid.Column="0" x:Name="yesterdayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</Grid>
</ui:Card>
<ui:Card Grid.Column="2" Margin="5, 10, 10, 10">
<StackPanel Orientation="Vertical">
<ui:TextBlock Text="Ehavi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="monthTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<ui:ProgressRing x:Name="monthProgressRing" Grid.Column="0" Width="10" Height="10" VerticalAlignment="Center" HorizontalAlignment="Left" IsIndeterminate="True" Visibility="Hidden" />
<StackPanel Orientation="Vertical" Grid.Column="0">
<ui:TextBlock Text="Ehavi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="monthTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
<StackPanel Orientation="Vertical" Grid.Column="1">
<ui:TextBlock Text="Átlag per nap" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="averageTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
</Grid>
</ui:Card>
</Grid>
@@ -73,7 +94,7 @@
</Grid.ColumnDefinitions>
<ui:SymbolIcon Symbol="Search24" Grid.Row="0" Margin="30, 10, 10, 10" />
<ui:TextBox Grid.Row="0" Grid.Column="1" Margin="10" x:Name="searchTextBox" PlaceholderText="Keresés..." TextChanged="SearchTextBoxTextChanged" />
<ui:TextBox Grid.Row="0" Grid.Column="1" Margin="10" x:Name="searchTextBox" PlaceholderText="Keresés..." TextChanged="SearchTextBoxTextChanged" KeyUp="searchTextBox_KeyUp" />
<ui:Card Grid.Row="1" Grid.ColumnSpan="2" Margin="10, 10, 10, 10"
VerticalAlignment="Stretch" HorizontalAlignment="Stretch" VerticalContentAlignment="Stretch" HorizontalContentAlignment="Stretch">
@@ -85,6 +106,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="x"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto" SharedSizeGroup="y"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
@@ -92,28 +114,25 @@
<RowDefinition Height="2*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Column="0" Grid.Row="0" Grid.RowSpan="2" VerticalAlignment="Center" FontSize="20" FontFamily="/Resources/Inter.ttf#Inter"
Margin="5, 0, 10, 0" Text="{Binding IssueNumber}" />
Margin="5, 0, 10, 0" Text="{Binding Id}" />
<TextBlock Grid.Column="1" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" FontSize="14"
Text="{Binding IssueName}" TextTrimming="CharacterEllipsis" />
Text="{Binding Subject}" TextTrimming="CharacterEllipsis" ToolTip="{Binding Subject}" ToolTipService.InitialShowDelay="500" />
<TextBlock Grid.Column="1" Grid.Row="1" Grid.RowSpan="1" VerticalAlignment="Center" FontSize="10" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}"
Text="{Binding ProjectName}" />
<TextBlock Grid.Column="2" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="14"
Margin="20, 0, 10, 0" Text="{Binding SpentTime, StringFormat=N2}" />
<TextBlock Grid.Column="2" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="12"
Margin="20, 0, 10, 0" Text="{Binding StatusName}" />
<TextBlock Grid.Column="2" Grid.Row="1" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="10"
Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" Margin="20, 0, 10, 0" Text="{Binding LastUpdate}" />
Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" Margin="20, 0, 0, 0" Text="{Binding LastUpdate}"
ToolTip="{Binding UpdatedOn}" ToolTipService.InitialShowDelay="200" />
<ui:Button x:Name="openTicketButton" Grid.RowSpan="2" Grid.Column="3" Margin="10, 0, 10, 0" Click="openTicketButton_Click">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Open24" />
</ui:Button.Icon>
</ui:Button>
</Grid>
</DataTemplate>
</ui:ListView.ItemTemplate>
</ui:ListView>
<!--<ui:DataGrid Margin="0, 0, 0, 0" x:Name="issuesDataGrid" AutoGenerateColumns="False" IsReadOnly="True" SelectionChanged="DataGridSelectionChanged" >
<DataGrid.Columns>
<DataGridTextColumn Header="Jegy" Binding="{Binding IssueNumber}" Width="Auto"/>
<DataGridTextColumn Header="Projekt" Binding="{Binding ProjectName}" MaxWidth="150"/>
<DataGridTextColumn Header="Név" Binding="{Binding IssueName}" Width="1*"/>
<DataGridTextColumn Header="Idő" Binding="{Binding SpentTime}" Width="Auto"/>
</DataGrid.Columns>
</ui:DataGrid>-->
</ui:Card>
<ProgressBar x:Name="progressBar" Grid.Row="2" Grid.ColumnSpan="2" Height="10" Margin="10" VerticalAlignment="Bottom" Minimum="0" Maximum="100" Value="0"/>
</Grid>
@@ -156,13 +175,14 @@
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="1*"/>
<RowDefinition Height="1*"/>
</Grid.RowDefinitions>
<ui:Button x:Name="closeButton" Grid.Row="0" Grid.Column="0" Margin="0, 0, 5, 5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Lezár" Click="CloseButtonClick" >
VerticalAlignment="Stretch" Content="Státusz..." Click="CloseButtonClick" >
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Checkmark24"/>
</ui:Button.Icon>
@@ -173,14 +193,14 @@
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Globe24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="newButton" Grid.Row="0" Grid.Column="2" Margin="5, 0, 0, 5" HorizontalAlignment="Stretch"
<ui:Button x:Name="newButton" Grid.Row="0" Grid.Column="2" Margin="5, 0, 5, 5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Új jegy" >
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="New24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="apiButton" Grid.Row="1" Grid.Column="0" Margin="0, 5, 5, 0" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="API kulcs" Click="ApiButtonClicked">
VerticalAlignment="Stretch" Content="API kulcs..." Click="ApiButtonClicked">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Key24"/>
</ui:Button.Icon>
@@ -191,13 +211,37 @@
<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">
<ui:Button x:Name="apiLinkButton" Content="API kulcs link" HorizontalAlignment="Stretch" Margin="10" Padding="10" Click="apiLinkButton_Click" />
@@ -210,6 +254,7 @@
</ui:Flyout>
</Grid>
<ui:TextBlock x:Name="statusTextBlock" Grid.Row="4" Grid.ColumnSpan="6" FontSize="8" Text="Staus: OK" Margin="10" />
<ui:ProgressRing x:Name="progressRing" Grid.Row="4" Height="10" Width="10" Margin="10" HorizontalAlignment="Left" IsIndeterminate="True" />
<ui:TextBlock x:Name="statusTextBlock" Grid.Row="4" Grid.ColumnSpan="6" FontSize="8" Text="Staus: OK" Margin="30, 10, 10, 10" />
</Grid>
</ui:FluentWindow>

View File

@@ -1,11 +1,13 @@
using BlueMine.Redmine;
using Blueberry;
using Blueberry.Redmine;
using Blueberry.Redmine.Dto;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
using Wpf.Ui.Controls;
using static BlueMine.Redmine.RedmineDto;
namespace BlueMine
{
@@ -15,12 +17,13 @@ namespace BlueMine
public partial class MainWindow : FluentWindow
{
private readonly RedmineManager _manager;
private readonly SettingsManager _settings;
private readonly RedmineSettingsManager _settings;
private readonly RedmineConfig _config;
private List<IssueItem> _issues = [];
public ObservableCollection<IssueItem> IssuesList { get; set; } = [];
private List<IssueList.Issue> _issues = [];
public ObservableCollection<IssueList.Issue> IssuesList { get; set; } = [];
public ObservableCollection<StatusList.IssueStatus> StatusList { get; set; } = [];
public MainWindow(RedmineManager manager, SettingsManager settings, RedmineConfig config)
public MainWindow(RedmineManager manager, RedmineSettingsManager settings, RedmineConfig config)
{
_settings = settings;
_config = config;
@@ -33,11 +36,14 @@ namespace BlueMine
{
apiUrlTextBox.Text = _config.RedmineUrl;
apiPasswordBox.PlaceholderText = new string('●', _config.ApiKey.Length);
mainCalendar.SelectedDate = DateTime.Today;
if(await TestConnection())
{
await LoadIssues();
await GetHours();
Task loadIssuesTask = LoadIssues();
Task getHoursTask = GetHours();
await Task.WhenAll(loadIssuesTask, getHoursTask);
}
}
@@ -51,6 +57,7 @@ namespace BlueMine
var progress = new Progress<(int current, int total)>();
progress.ProgressChanged += (s, args) =>
{
progressRing.Visibility = Visibility.Visible;
int current = args.current;
int total = args.total;
statusTextBlock.Text = $"{message}: {current} / {total}";
@@ -76,7 +83,7 @@ namespace BlueMine
private void apiLinkButton_Click(object sender, RoutedEventArgs e)
{
string url = $"{_config.RedmineUrl}/my/account";
string url = $"{apiUrlTextBox.Text}/my/account";
var psi = new ProcessStartInfo
{
@@ -105,15 +112,6 @@ namespace BlueMine
}
}
private void DataGridSelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
/*if (issuesDataGrid.SelectedItem is IssueItem item)
{
IssueNumberTextBox.Text = item.IssueNumber.ToString();
}*/
}
private void SearchTextBoxTextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
{
FilterIssues();
@@ -121,8 +119,10 @@ namespace BlueMine
private async void RefreshButtonClick(object sender, RoutedEventArgs e)
{
await LoadIssues();
await GetHours();
Task loadIssuesTask = LoadIssues();
Task getHoursTask = GetHours();
await Task.WhenAll(loadIssuesTask, getHoursTask);
}
private void BrowserButtonClick(object sender, RoutedEventArgs e)
@@ -143,43 +143,23 @@ namespace BlueMine
private async void CloseButtonClick(object sender, RoutedEventArgs e)
{
var issueNum = IssueNumberTextBox.Text;
if (int.TryParse(issueNum, out var issueId))
{
try
{
await _manager.CloseIssueAsync(issueId);
await new Wpf.Ui.Controls.MessageBox
{
Title = "Sikeres művelet",
Content = $"A(z) {issueId} számú jegy lezárva.",
}.ShowDialogAsync();
}
catch (Exception)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Hiba",
Content = $"A(z) {issueId} számú jegy lezárása sikertelen.",
}.ShowDialogAsync();
}
}
StatusList.Clear();
var s = await _manager.GetStatusesAsync();
foreach (var status in s)
StatusList.Add(status);
await new Wpf.Ui.Controls.MessageBox
{
Title = "Hiba",
Content = "Érvénytelen jegyszám.",
}.ShowDialogAsync();
statusFlyout.IsOpen = true;
}
private async void FixButtonClick(object sender, RoutedEventArgs e)
{
var progress = UpdateProgress("Idők javítása:");
progressRing.Visibility = Visibility.Visible;
var i = 0;
foreach (var date in mainCalendar.SelectedDates)
{
var hours = 8 - await _manager.GetLoggedHoursAsync(date, date);
var hours = 8 - await _manager.GetCurrentUserTimeAsync(date, date);
if (hours <= 0)
continue;
var message = Constants.GenericMessages[Random.Shared.Next(Constants.GenericMessages.Length)];
@@ -189,6 +169,7 @@ namespace BlueMine
i++;
}
progressBar.Value = 0;
progressRing.Visibility = Visibility.Hidden;
await GetHours();
statusTextBlock.Text = "Idők javítva";
@@ -196,6 +177,16 @@ namespace BlueMine
private async void sendButton_Click(object sender, RoutedEventArgs e)
{
if(mainCalendar.SelectedDates.Count == 0)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Nap hiba",
Content = "Nincs kijelölve nap."
}.ShowDialogAsync();
return;
}
if(int.TryParse(IssueNumberTextBox.Text, out var issueId)
&& double.TryParse(HoursTextBox.Text, out var hours))
{
@@ -209,16 +200,17 @@ namespace BlueMine
return;
}
var total = mainCalendar.SelectedDates.Count;
var progress = UpdateProgress("Idők beköldése:");
var progress = UpdateProgress("Idők beküldése:");
progressRing.Visibility = Visibility.Visible;
for (int i = 0; i < total; i++)
{
await _manager.LogTimeAsync(issueId, hours, MessageTextBox.Text, mainCalendar.SelectedDates[i]);
progress.Report((i, total));
progress.Report((i + 1, total));
}
progressBar.Value = 0;
statusTextBlock.Text = "Idők beküldve";
await GetHours();
progressBar.Value = 0;
progressRing.Visibility = Visibility.Hidden;
statusTextBlock.Text = "Idők beküldve";
} else
{
await new Wpf.Ui.Controls.MessageBox
@@ -233,11 +225,128 @@ namespace BlueMine
private void ListView_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
var lv = sender as ListView;
if(lv != null && lv.SelectedItem is IssueItem item)
if(lv != null && lv.SelectedItem is IssueList.Issue item)
{
IssueNumberTextBox.Text = item.IssueNumber.ToString();
IssueNumberTextBox.Text = item.Id.ToString();
}
}
private async void statusSaveButton_Click(object sender, RoutedEventArgs e)
{
var issueNum = IssueNumberTextBox.Text;
if (int.TryParse(issueNum, out var issueId))
{
try
{
var status = statusComboBox.SelectedItem as StatusList.IssueStatus;
if (status == null)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Érvénytelen státusz",
Content = "Státusz kiválasztása sikertelen."
}.ShowDialogAsync();
return;
}
await _manager.SetIssueStatusAsync(issueId, status.Id);
var oldIssue = IssuesList.First(x=>x.Id == issueId);
var newIssue = await _manager.GetSimpleIssueAsync(issueId);
var index = IssuesList.IndexOf(oldIssue);
IssuesList.Insert(index, newIssue);
IssuesList.Remove(oldIssue);
await new Wpf.Ui.Controls.MessageBox
{
Title = "Sikeres művelet",
Content = $"A(z) {issueId} számú jegy új státusza: {status.Name}.",
}.ShowDialogAsync();
}
catch (Exception)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Hiba",
Content = $"A(z) {issueId} számú jegy módosítása sikertelen.",
}.ShowDialogAsync();
}
}
else
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Hiba",
Content = "Érvénytelen jegyszám.",
}.ShowDialogAsync();
}
}
private async void searchTextBox_KeyUp(object sender, KeyEventArgs e)
{
if(e.Key == Key.Enter && searchTextBox.Text.Length > 0)
{
if (int.TryParse(searchTextBox.Text, out var issueId))
{
try
{
statusTextBlock.Text = "Jegy keresése...";
progressRing.Visibility = Visibility.Visible;
var issue = await _manager.GetSimpleIssueAsync(issueId);
IssuesList.Clear();
IssuesList.Add(issue);
statusTextBlock.Text = "Jegy betöltve";
progressRing.Visibility = Visibility.Hidden;
} catch (Exception) {
statusTextBlock.Text = "Jegy nem található";
progressRing.Visibility = Visibility.Hidden;
}
}
}
}
private async void openTicketButton_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement button && button.DataContext is IssueList.Issue item)
{
// 2. Access the property directly from your model
var issueId = item.Id;
try
{
statusTextBlock.Text = "Jegy betöltése...";
progressRing.Visibility = Visibility.Visible;
var issue = await _manager.GetIssueAsync(issueId);
statusTextBlock.Text = "Jegy betöltve";
progressRing.Visibility = Visibility.Hidden;
var issueWindow = new IssueWindow(issue, _manager, _config);
issueWindow.Show();
} catch (Exception)
{
statusTextBlock.Text = "Jegy betöltés sikertelen";
progressRing.Visibility = Visibility.Hidden;
}
}
}
private async void trackerButton_Click(object sender, RoutedEventArgs e)
{
if (int.TryParse(IssueNumberTextBox.Text, out var issueId))
await OpenTimeTracker(issueId);
}
private async void hoursButton_Click(object sender, RoutedEventArgs e)
{
await new Wpf.Ui.Controls.MessageBox
{
Title = "Under construction"
}.ShowDialogAsync();
}
}
public partial class MainWindow : FluentWindow
@@ -295,22 +404,43 @@ namespace BlueMine
public async Task GetHours()
{
var today = await _manager.GetLoggedHoursAsync(DateTime.Today, DateTime.Today);
var yesterday = await _manager.GetLoggedHoursAsync(DateTime.Today.AddDays(-1), DateTime.Today.AddDays(-1));
todayProgressRing.Visibility =
yesterdayProgressRing.Visibility =
monthProgressRing.Visibility = Visibility.Visible;
var today = await _manager.GetCurrentUserTimeTodayAsync();
var yesterday = await _manager.GetCurrentUserTimeYesterdayAsync();
var thisMonth = await _manager.GetCurrentUserTimeThisMonthAsync();
var m = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
var thisMonth = await _manager.GetLoggedHoursAsync(m, m.AddMonths(1).AddDays(-1));
int workingDays = 0;
DateTime currentDate = DateTime.Today;
for (int day = 1; day <= currentDate.Day; day++)
{
var dateToCheck = new DateTime(currentDate.Year, currentDate.Month, day);
if (dateToCheck.DayOfWeek != DayOfWeek.Saturday &&
dateToCheck.DayOfWeek != DayOfWeek.Sunday)
{
workingDays++;
}
}
var avgHours = Math.Round(thisMonth/workingDays, 2);
todayTimeLabel.Text = today.ToString();
yesterdayTimeLabel.Text = yesterday.ToString();
monthTimeLabel.Text = thisMonth.ToString();
averageTimeLabel.Text = avgHours.ToString();
todayProgressRing.Visibility =
yesterdayProgressRing.Visibility =
monthProgressRing.Visibility = Visibility.Hidden;
}
public void FilterIssues()
{
var list = string.IsNullOrWhiteSpace(searchTextBox.Text)
? _issues
: _issues.Where(issue => issue.IssueName.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase)
|| issue.IssueNumber.ToString().Contains(searchTextBox.Text)
: _issues.Where(issue => issue.Subject.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase)
|| issue.Id.ToString().Contains(searchTextBox.Text)
|| issue.ProjectName.Contains(searchTextBox.Text, StringComparison.OrdinalIgnoreCase));
IssuesList.Clear();
foreach (var item in list)
@@ -321,9 +451,14 @@ namespace BlueMine
public async Task LoadIssues()
{
_issues.Clear();
_issues.AddRange(Constants.StaticTickets);
_issues.AddRange(await _manager.GetCurrentIssuesAsync(UpdateProgress("Jegyek letöltése:")));
statusTextBlock.Text = "Jegyek letöltése...";
progressRing.Visibility = Visibility.Visible;
foreach (var issueId in Constants.StaticTickets)
_issues.Add(await _manager.GetSimpleIssueAsync(issueId));
_issues.AddRange(await _manager.GetCurrentUserIssuesAsync(progress: UpdateProgress("Jegyek letöltése:")));
progressBar.Value = 0;
progressRing.Visibility = Visibility.Hidden;
FilterIssues();
statusTextBlock.Text = "Jegyek letöltve";
}
@@ -359,19 +494,49 @@ namespace BlueMine
}
public async Task<bool> TestConnection()
{
if (!await _manager.IsRedmineAvailable())
statusTextBlock.Text = $"Kapcsolódás Redminehoz...";
int maxRetries = 3;
int timeoutSeconds = 1; // Force kill after 5s
for (int i = 0; i < maxRetries; i++)
{
DisableUi();
apiButton.Appearance = Wpf.Ui.Controls.ControlAppearance.Primary;
return false;
}
else
{
EnableUi();
apiButton.Appearance = Wpf.Ui.Controls.ControlAppearance.Secondary;
statusTextBlock.Text = "Kapcsolódva";
return true;
try
{
// Creates a token that cancels automatically
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
// Pass the token. If it hangs, this throws OperationCanceledException
if (await _manager.IsRedmineAvailable(cts.Token))
{
EnableUi();
apiButton.Appearance = ControlAppearance.Secondary;
statusTextBlock.Text = "Kapcsolódva";
return true;
}
}
catch (Exception)
{
// Ignore timeout/error and try again unless it's the last attempt
if (i == maxRetries - 1) break;
}
statusTextBlock.Text = $"Kapcsolódási hiba. Újrapróbálkozás: {i + 1}/{maxRetries}";
// Wait 1 second before retrying
await Task.Delay(1000);
}
// All attempts failed
DisableUi();
apiButton.Appearance = ControlAppearance.Primary;
return false;
}
public async Task OpenTimeTracker(int issueId)
{
var i = await _manager.GetSimpleIssueAsync(issueId);
var timer = new TimeTrackerWindow(_config, _manager, i);
timer.Show();
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

120
Blueberry/UpdateManager.cs Normal file
View File

@@ -0,0 +1,120 @@
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Text.Json;
namespace Blueberry
{
public class UpdateManager
{
private const string releaseUrl = "https://git.technopunk.space/api/v1/repos/tomi/Blueberry/releases/latest";
private const string CurrentVersion = "0.1.0";
public async Task CheckAndInstallAsync()
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("User-Agent", "Blueberry-Updater");
try
{
var json = client.GetStringAsync(releaseUrl).ConfigureAwait(false).GetAwaiter().GetResult();
var release = JsonSerializer.Deserialize<Root>(json);
if (release == null)
throw new NullReferenceException();
if (release.tag_name != CurrentVersion)
{
var file = release.assets.Find(x => x.name.Contains(".zip")) ?? throw new NullReferenceException();
string downloadUrl = file.browser_download_url;
await PerformUpdate(client, downloadUrl);
}
}
catch (Exception)
{
}
}
private async Task PerformUpdate(HttpClient client, string url)
{
string tempZip = Path.Combine(Path.GetTempPath(), "blueberry_update.zip");
string currentExe = Process.GetCurrentProcess().MainModule.FileName;
string appDir = AppDomain.CurrentDomain.BaseDirectory;
// 1. Download
var data = await client.GetByteArrayAsync(url);
File.WriteAllBytes(tempZip, data);
// 2. Create a temporary batch script to handle the swap
// We use a small delay (timeout) to allow the main app to close fully
string psScript = $@"
# Wait for the main app to close completely
Start-Sleep -Seconds 2
$exePath = '{currentExe}'
$zipPath = '{tempZip}'
$destDir = '{appDir}'
# Retry logic for deletion (in case antivirus or OS holds the lock)
$maxRetries = 10
$retryCount = 0
while ($retryCount -lt $maxRetries) {{
try {{
# Attempt to delete the old executable
if (Test-Path $exePath) {{ Remove-Item $exePath -Force -ErrorAction Stop }}
break # If successful, exit loop
}}
catch {{
Start-Sleep -Milliseconds 500
$retryCount++
}}
}}
# Unzip the new version
Expand-Archive -Path $zipPath -DestinationPath $destDir -Force
# CLEANUP: Delete the zip
Remove-Item $zipPath -Force
# RESTART: Launch the new executable
# 'Start-Process' is the robust way to launch detached processes in PS
Start-Process -FilePath $exePath -WorkingDirectory $destDir
# SELF-DESTRUCT: Remove this script
Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force
";
string psPath = Path.Combine(Path.GetTempPath(), "blueberry_updater.ps1");
File.WriteAllText(psPath, psScript);
// 3. Execute the PowerShell script hidden
var startInfo = new ProcessStartInfo()
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{psPath}\"",
UseShellExecute = false,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
Process.Start(startInfo);
// 4. Kill the current app immediately so the script can delete it
System.Windows.Application.Current.Shutdown();
}
public class Root
{
public string tag_name { get; set; }
public List<Asset> assets { get; set; }
}
public class Asset
{
public string name { get; set; }
public string browser_download_url { get; set; }
}
}
}