initial commit
This commit is contained in:
135
.gitignore
vendored
Normal file
135
.gitignore
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
|
||||
# User-specific files
|
||||
*.suo
|
||||
*.user
|
||||
*.sln.docstates
|
||||
.vs/
|
||||
|
||||
# Build results
|
||||
./BlueberryUpdater/*.zip
|
||||
FinalBuild/
|
||||
[Dd]ebug/
|
||||
[Rr]elease/
|
||||
x64/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_i.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.log
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.Publish.xml
|
||||
*.pubxml
|
||||
*.azurePubxml
|
||||
|
||||
# NuGet Packages Directory
|
||||
## TODO: If you have NuGet Package Restore enabled, uncomment the next line
|
||||
packages/
|
||||
## TODO: If the tool you use requires repositories.config, also uncomment the next line
|
||||
!packages/repositories.config
|
||||
|
||||
# Windows Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Windows Store app package directory
|
||||
AppPackages/
|
||||
|
||||
# Others
|
||||
sql/
|
||||
*.Cache
|
||||
ClientBin/
|
||||
[Ss]tyle[Cc]op.*
|
||||
![Ss]tyle[Cc]op.targets
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.[Pp]ublish.xml
|
||||
|
||||
*.publishsettings
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file to a newer
|
||||
# Visual Studio version. Backup files are not needed, because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
|
||||
# SQL Server files
|
||||
App_Data/*.mdf
|
||||
App_Data/*.ldf
|
||||
|
||||
# =========================
|
||||
# Windows detritus
|
||||
# =========================
|
||||
|
||||
# Windows image file caches
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
|
||||
# Folder config file
|
||||
Desktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Mac desktop service store files
|
||||
.DS_Store
|
||||
|
||||
_NCrunch*
|
||||
31
BlueMine.sln
Normal file
31
BlueMine.sln
Normal file
@@ -0,0 +1,31 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.14.36603.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlueMine", "BlueMine\BlueMine.csproj", "{201018E0-4328-4B0A-8BD7-0E3AC6155A68}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlueberryUpdater", "BlueberryUpdater\BlueberryUpdater.csproj", "{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{201018E0-4328-4B0A-8BD7-0E3AC6155A68}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3DFA0D6A-39BE-471E-9839-8F36B5A487FA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {3C0CD25D-E10C-4E1B-88BC-C60755F9B5FB}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
18
BlueMine/App.xaml
Normal file
18
BlueMine/App.xaml
Normal 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
78
BlueMine/App.xaml.cs
Normal 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
10
BlueMine/AssemblyInfo.cs
Normal 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
43
BlueMine/BlueMine.csproj
Normal 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
49
BlueMine/Constants.cs
Normal 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
215
BlueMine/MainWindow.xaml
Normal 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
377
BlueMine/MainWindow.xaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
BlueMine/Redmine/AsyncLock.cs
Normal file
29
BlueMine/Redmine/AsyncLock.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
BlueMine/Redmine/IRedmineCache.cs
Normal file
10
BlueMine/Redmine/IRedmineCache.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace BlueMine.Redmine
|
||||
{
|
||||
public interface IRedmineCache<T>
|
||||
{
|
||||
void RefreshCache(List<T> newItems);
|
||||
void InvalidateCache();
|
||||
bool IsCacheValid();
|
||||
List<T> GetItems();
|
||||
}
|
||||
}
|
||||
18
BlueMine/Redmine/IRedmineConnect.cs
Normal file
18
BlueMine/Redmine/IRedmineConnect.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
BlueMine/Redmine/IRedmineManager.cs
Normal file
16
BlueMine/Redmine/IRedmineManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
17
BlueMine/Redmine/RedmineApiException.cs
Normal file
17
BlueMine/Redmine/RedmineApiException.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
BlueMine/Redmine/RedmineAuthHandler.cs
Normal file
51
BlueMine/Redmine/RedmineAuthHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
BlueMine/Redmine/RedmineCache.cs
Normal file
64
BlueMine/Redmine/RedmineCache.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
BlueMine/Redmine/RedmineConfig.cs
Normal file
12
BlueMine/Redmine/RedmineConfig.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
379
BlueMine/Redmine/RedmineConnect.cs
Normal file
379
BlueMine/Redmine/RedmineConnect.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
185
BlueMine/Redmine/RedmineDto.cs
Normal file
185
BlueMine/Redmine/RedmineDto.cs
Normal 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.
|
||||
206
BlueMine/Redmine/RedmineManager.cs
Normal file
206
BlueMine/Redmine/RedmineManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
BlueMine/Resources/Inter.ttf
Normal file
BIN
BlueMine/Resources/Inter.ttf
Normal file
Binary file not shown.
BIN
BlueMine/Resources/Roboto.ttf
Normal file
BIN
BlueMine/Resources/Roboto.ttf
Normal file
Binary file not shown.
BIN
BlueMine/Resources/Zalando.ttf
Normal file
BIN
BlueMine/Resources/Zalando.ttf
Normal file
Binary file not shown.
91
BlueMine/SettingsManager.cs
Normal file
91
BlueMine/SettingsManager.cs
Normal 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
BIN
BlueMine/bb.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
24
BlueberryUpdater/BlueberryUpdater.csproj
Normal file
24
BlueberryUpdater/BlueberryUpdater.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>bb.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="bb.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="AppPayload.zip">
|
||||
<Visible>false</Visible>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
195
BlueberryUpdater/Program.cs
Normal file
195
BlueberryUpdater/Program.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
|
||||
namespace BlueberryUpdater
|
||||
{
|
||||
internal class Program
|
||||
{
|
||||
static string appName = "Blueberry";
|
||||
static string updaterName = appName + "Updater";
|
||||
static string installPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), appName);
|
||||
|
||||
static void Main(string[] args)
|
||||
{
|
||||
if (Directory.Exists(installPath) && File.Exists(Path.Combine(installPath, appName + ".exe")))
|
||||
Uninstall();
|
||||
else
|
||||
Install();
|
||||
}
|
||||
|
||||
private static void Uninstall()
|
||||
{
|
||||
Console.WriteLine("Would you like to uninstall Blueberry? [y/N]");
|
||||
var key = Console.ReadLine();
|
||||
if (key == null ||key.ToLower() != "y")
|
||||
return;
|
||||
|
||||
var appdata = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), appName);
|
||||
|
||||
Console.WriteLine("Removing Blueberry...");
|
||||
var dirs = Directory.GetDirectories(installPath);
|
||||
var files = Directory.GetFiles(installPath);
|
||||
var total = dirs.Length + files.Length;
|
||||
var i = 0;
|
||||
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
i++;
|
||||
Directory.Delete(dir, true);
|
||||
DrawProgressBar((int)((double)i / total * 100), dir.Split('\\').Last());
|
||||
}
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
i++;
|
||||
if (file.Split('\\').Last() == updaterName + ".exe")
|
||||
continue;
|
||||
File.Delete(file);
|
||||
i++;
|
||||
DrawProgressBar((int)((double)i / total * 100), file.Split('\\').Last());
|
||||
}
|
||||
Directory.Delete(appdata, true);
|
||||
|
||||
DrawProgressBar(100, "Done");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Uninstall Complete!");
|
||||
|
||||
Console.WriteLine("Press any key to exit...");
|
||||
Console.ReadKey();
|
||||
|
||||
SelfDelete();
|
||||
}
|
||||
|
||||
private static void Install()
|
||||
{
|
||||
try
|
||||
{
|
||||
string resourceName = "BlueberryUpdater.AppPayload.zip"; // Format: Namespace.Filename
|
||||
|
||||
Console.WriteLine($"Installing to {installPath}...");
|
||||
|
||||
// 1. Clean existing install if necessary
|
||||
if (Directory.Exists(installPath))
|
||||
{
|
||||
Directory.Delete(installPath, true);
|
||||
}
|
||||
Directory.CreateDirectory(installPath);
|
||||
|
||||
// 2. Extract Embedded Resource
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
using (Stream stream = assembly.GetManifestResourceStream(resourceName))
|
||||
{
|
||||
if (stream == null)
|
||||
{
|
||||
throw new Exception($"Resource '{resourceName}' not found. Check Embedded Resource settings.");
|
||||
}
|
||||
|
||||
using (ZipArchive archive = new(stream))
|
||||
{
|
||||
int totalEntries = archive.Entries.Count;
|
||||
int currentEntry = 0;
|
||||
|
||||
foreach (ZipArchiveEntry entry in archive.Entries)
|
||||
{
|
||||
currentEntry++;
|
||||
|
||||
// Calculate percentage
|
||||
int percent = (int)((double)currentEntry / totalEntries * 100);
|
||||
|
||||
// Draw Progress Bar
|
||||
DrawProgressBar(percent, entry.Name);
|
||||
|
||||
// Create the full path
|
||||
string destinationPath = Path.GetFullPath(Path.Combine(installPath, entry.FullName));
|
||||
|
||||
// Security check: prevent ZipSlip (writing outside target folder)
|
||||
if (!destinationPath.StartsWith(installPath, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// Handle folders vs files
|
||||
if (string.IsNullOrEmpty(entry.Name)) // It's a directory
|
||||
{
|
||||
Directory.CreateDirectory(destinationPath);
|
||||
}
|
||||
else // It's a file
|
||||
{
|
||||
// Ensure the directory exists (zipped files might not list their dir first)
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath));
|
||||
entry.ExtractToFile(destinationPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MoveUpdater();
|
||||
|
||||
DrawProgressBar(100, "Done");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Installation Complete!");
|
||||
|
||||
// Optional: Create Shortcut logic here
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine("Press any key to exit...");
|
||||
Console.ReadKey();
|
||||
}
|
||||
|
||||
public static void SelfDelete()
|
||||
{
|
||||
string exePath = Process.GetCurrentProcess().MainModule.FileName;
|
||||
string directoryToDelete = Path.GetDirectoryName(exePath);
|
||||
|
||||
string args = $"/C timeout /t 2 /nobreak > Nul & del \"{exePath}\" & rmdir /q \"{directoryToDelete}\"";
|
||||
|
||||
ProcessStartInfo psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = args,
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
Process.Start(psi);
|
||||
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
public static void MoveUpdater()
|
||||
{
|
||||
string currentExe = Process.GetCurrentProcess().MainModule.FileName;
|
||||
|
||||
var updaterPath = Path.Combine(installPath, updaterName + ".exe");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(updaterPath));
|
||||
File.Copy(currentExe, updaterPath, overwrite: true);
|
||||
}
|
||||
|
||||
static void DrawProgressBar(int percent, string filename)
|
||||
{
|
||||
// Move cursor to start of line
|
||||
Console.CursorLeft = 0;
|
||||
|
||||
// Limit filename length for clean display
|
||||
string shortName = filename.Length > 20 ? filename.Substring(0, 17) + "..." : filename.PadRight(20);
|
||||
|
||||
Console.Write("[");
|
||||
int width = Console.WindowWidth - 1; // Width of the bar
|
||||
int progress = (int)((percent / 100.0) * width);
|
||||
|
||||
// Draw filled part
|
||||
Console.Write(new string('#', progress));
|
||||
// Draw empty part
|
||||
Console.Write(new string('-', width - progress));
|
||||
|
||||
Console.Write($"] {percent}% {shortName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
79
BlueberryUpdater/app.manifest
Normal file
79
BlueberryUpdater/app.manifest
Normal file
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- UAC Manifest Options
|
||||
If you want to change the Windows User Account Control level replace the
|
||||
requestedExecutionLevel node with one of the following.
|
||||
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
|
||||
|
||||
Specifying requestedExecutionLevel element will disable file and registry virtualization.
|
||||
Remove this element if your application requires this virtualization for backwards
|
||||
compatibility.
|
||||
-->
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows Vista -->
|
||||
<!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
|
||||
|
||||
<!-- Windows 7 -->
|
||||
<!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
|
||||
|
||||
<!-- Windows 8 -->
|
||||
<!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
|
||||
|
||||
<!-- Windows 8.1 -->
|
||||
<!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<!--<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />-->
|
||||
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher
|
||||
DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need
|
||||
to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should
|
||||
also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config.
|
||||
|
||||
Makes the application long-path aware. See https://docs.microsoft.com/windows/win32/fileio/maximum-file-path-limitation -->
|
||||
<!--
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
-->
|
||||
|
||||
<!-- Enable themes for Windows common controls and dialogs (Windows XP and later) -->
|
||||
<!--
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
-->
|
||||
|
||||
</assembly>
|
||||
BIN
BlueberryUpdater/bb.ico
Normal file
BIN
BlueberryUpdater/bb.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
31
build.installer.ps1
Normal file
31
build.installer.ps1
Normal file
@@ -0,0 +1,31 @@
|
||||
# Configuration
|
||||
$wpfProject = ".\BlueMine\BlueMine.csproj"
|
||||
$installerProject = ".\BlueberryUpdater\BlueberryUpdater.csproj"
|
||||
$installerDir = ".\BlueberryUpdater"
|
||||
$zipPath = "$installerDir\AppPayload.zip"
|
||||
$outputDir = ".\FinalBuild"
|
||||
|
||||
# 1. Clean up previous artifacts
|
||||
Write-Host "Cleaning up..." -ForegroundColor Cyan
|
||||
if (Test-Path $zipPath) { Remove-Item $zipPath }
|
||||
if (Test-Path $outputDir) { Remove-Item $outputDir -Recurse }
|
||||
if (Test-Path ".\TempWpfPublish") { Remove-Item ".\TempWpfPublish" -Recurse }
|
||||
|
||||
# 2. Publish WPF App (Self-Contained)
|
||||
Write-Host "Publishing WPF App..." -ForegroundColor Cyan
|
||||
dotnet publish $wpfProject -c Release -r win-x64 --self-contained true -o ".\TempWpfPublish" /p:DebugType=None /p:DebugSymbols=false
|
||||
|
||||
# 3. Zip the Published WPF App
|
||||
Write-Host "Creating Payload Zip..." -ForegroundColor Cyan
|
||||
Compress-Archive -Path ".\TempWpfPublish\*" -DestinationPath $zipPath
|
||||
|
||||
# 4. Publish Installer (Single File + Embeds the Zip)
|
||||
Write-Host "Building Final Installer..." -ForegroundColor Cyan
|
||||
dotnet publish $installerProject -c Release -r win-x64 --self-contained true -o $outputDir -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
|
||||
|
||||
# 5. Cleanup Temp Files
|
||||
Remove-Item ".\TempWpfPublish" -Recurse
|
||||
# Optional: Remove the zip from the source folder if you want to keep it clean
|
||||
# Remove-Item $zipPath
|
||||
|
||||
Write-Host "Build Complete! Installer is in $outputDir" -ForegroundColor Green
|
||||
Reference in New Issue
Block a user