initial commit

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

135
.gitignore vendored Normal file
View 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
View 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
View File

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

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

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

10
BlueMine/AssemblyInfo.cs Normal file
View File

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

43
BlueMine/BlueMine.csproj Normal file
View File

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

49
BlueMine/Constants.cs Normal file
View File

@@ -0,0 +1,49 @@
using static BlueMine.Redmine.RedmineDto;
namespace BlueMine
{
internal class Constants
{
public static IssueItem[] StaticTickets = [
new IssueItem() {
IssueNumber = 705,
SpentTime = 0,
ProjectName = "OnLiveIT",
IssueName = "Megbeszélés",
IssueDescription = ""
},
new IssueItem() {
IssueNumber = 801,
SpentTime = 0,
ProjectName = "OnLiveIT",
IssueName = "Egyéb",
IssueDescription = ""
},
];
public static string[] GenericMessages = [
"Config reszelés",
"Telefon, mail, chat",
"Meet, email és egyéb",
"Tanulás, dokumentálás",
"Adminisztrációs cuccok",
"Doksi készítés, tanulás",
"Doksi túrás, hibakeresés",
"Kollégákkal kommunikáció",
"Adminisztrációs feladatok",
"Napi admin körök, redmine",
"SAP dokumnetáció, önképzés",
"Általános admin és papírmunka",
"Belső egyeztetések, meetingek",
"Jegyezés, emailek, chat, stb.",
"SAP doksik olvasása, önképzés",
"Jegyek átnézése, adminisztráció",
"VPN szívás, emailek, chat, stb.",
"Saját gép karbantartása, updatek",
"Technikai utánaolvasás, research",
"SAP Note-ok böngészése, tesztelés",
"Nem elszámolható hívások, email, chat",
"Nem elszámolható telefon, chat, email kommunikáció",
];
}
}

215
BlueMine/MainWindow.xaml Normal file
View File

@@ -0,0 +1,215 @@
<ui:FluentWindow x:Class="BlueMine.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:local="clr-namespace:BlueMine"
mc:Ignorable="d"
Icon="/bb.ico"
Title="Blueberry"
Height="720" Width="1280"
MinWidth="650" MinHeight="450"
d:DataContext="{d:DesignInstance local:MainWindow}"
Loaded="WindowLoaded">
<ui:FluentWindow.Resources>
<FontFamily x:Key="Roboto">/Resources/Roboto.ttf</FontFamily>
<FontFamily x:Key="Zalando">/Resources/Zalando.ttf</FontFamily>
<FontFamily x:Key="Inter">/Resources/Inter.ttf</FontFamily>
</ui:FluentWindow.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="1*" MinHeight="275"/>
<RowDefinition Height="1*" MaxHeight="200"/>
<RowDefinition Height="30"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.ColumnSpan="2" Title="Blueberry">
<ui:TitleBar.Icon>
<ui:ImageIcon Source="/bb.ico" />
</ui:TitleBar.Icon>
</ui:TitleBar>
<Grid Grid.Row="1" Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<ui:Card Margin="10, 10, 5, 10">
<StackPanel Orientation="Vertical">
<ui:TextBlock Text="Mai órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="todayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
</ui:Card>
<ui:Card Grid.Column="1" Margin="5, 10, 5, 10">
<StackPanel Orientation="Vertical">
<ui:TextBlock Text="Tegnapi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="yesterdayTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
</ui:Card>
<ui:Card Grid.Column="2" Margin="5, 10, 10, 10">
<StackPanel Orientation="Vertical">
<ui:TextBlock Text="Ehavi órák" FontSize="10" HorizontalAlignment="Center" Margin="-3" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" />
<ui:TextBlock x:Name="monthTimeLabel" Text="0.0" FontSize="22" FontWeight="Bold" HorizontalAlignment="Center" Margin="-4" FontFamily="/Resources/Inter.ttf#Inter" />
</StackPanel>
</ui:Card>
</Grid>
<Grid x:Name="TicketList" Grid.Row="1" Grid.Column="0" Grid.RowSpan="3">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="1*"/>
<RowDefinition Height="30"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<ui:SymbolIcon Symbol="Search24" Grid.Row="0" Margin="30, 10, 10, 10" />
<ui:TextBox Grid.Row="0" Grid.Column="1" Margin="10" x:Name="searchTextBox" PlaceholderText="Keresés..." TextChanged="SearchTextBoxTextChanged" />
<ui:Card Grid.Row="1" Grid.ColumnSpan="2" Margin="10, 10, 10, 10"
VerticalAlignment="Stretch" HorizontalAlignment="Stretch" VerticalContentAlignment="Stretch" HorizontalContentAlignment="Stretch">
<ui:ListView ItemsSource="{Binding IssuesList}" ScrollViewer.HorizontalScrollBarVisibility="Disabled" Grid.IsSharedSizeScope="True" SelectionChanged="ListView_SelectionChanged">
<ui:ListView.ItemTemplate>
<DataTemplate>
<Grid Margin="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="x"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="Auto" SharedSizeGroup="y"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="3*"/>
<RowDefinition Height="2*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Column="0" Grid.Row="0" Grid.RowSpan="2" VerticalAlignment="Center" FontSize="20" FontFamily="/Resources/Inter.ttf#Inter"
Margin="5, 0, 10, 0" Text="{Binding IssueNumber}" />
<TextBlock Grid.Column="1" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" FontSize="14"
Text="{Binding IssueName}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Grid.Row="1" Grid.RowSpan="1" VerticalAlignment="Center" FontSize="10" Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}"
Text="{Binding ProjectName}" />
<TextBlock Grid.Column="2" Grid.Row="0" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="14"
Margin="20, 0, 10, 0" Text="{Binding SpentTime, StringFormat=N2}" />
<TextBlock Grid.Column="2" Grid.Row="1" Grid.RowSpan="1" VerticalAlignment="Center" HorizontalAlignment="Right" FontSize="10"
Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}" Margin="20, 0, 10, 0" Text="{Binding LastUpdate}" />
</Grid>
</DataTemplate>
</ui:ListView.ItemTemplate>
</ui:ListView>
<!--<ui:DataGrid Margin="0, 0, 0, 0" x:Name="issuesDataGrid" AutoGenerateColumns="False" IsReadOnly="True" SelectionChanged="DataGridSelectionChanged" >
<DataGrid.Columns>
<DataGridTextColumn Header="Jegy" Binding="{Binding IssueNumber}" Width="Auto"/>
<DataGridTextColumn Header="Projekt" Binding="{Binding ProjectName}" MaxWidth="150"/>
<DataGridTextColumn Header="Név" Binding="{Binding IssueName}" Width="1*"/>
<DataGridTextColumn Header="Idő" Binding="{Binding SpentTime}" Width="Auto"/>
</DataGrid.Columns>
</ui:DataGrid>-->
</ui:Card>
<ProgressBar x:Name="progressBar" Grid.Row="2" Grid.ColumnSpan="2" Height="10" Margin="10" VerticalAlignment="Bottom" Minimum="0" Maximum="100" Value="0"/>
</Grid>
<Grid x:Name="InputFields" Grid.Row="2" Grid.Column="1" Grid.RowSpan="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="1*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<ui:Flyout Margin="10" Grid.Row="0" Placement="Top" x:Name="flyoutCalendar">
<Calendar x:Name="mainCalendar" Grid.Row="1" Grid.Column="1" Grid.RowSpan="1" Margin="10" SelectionMode="MultipleRange" SelectedDatesChanged="CalendarSelectedDatesChanged"/>
</ui:Flyout>
<Button Grid.Row="0" Grid.Column="1" x:Name="calendarButton" Content="2025-12-09" HorizontalAlignment="Stretch" Margin="10" Click="CalendarButtonClicked" />
<Label Grid.Row="0" Grid.Column="0" Content="Napok:" Margin="10" HorizontalAlignment="Center"/>
<Label Grid.Row="1" Grid.Column="0" Content="Jegy:" Margin="10" HorizontalAlignment="Center"/>
<Label Grid.Row="2" Grid.Column="0" Content="Óra:" Margin="10" HorizontalAlignment="Center"/>
<Label Grid.Row="3" Grid.Column="0" Content="Üzenet:" Margin="10" HorizontalAlignment="Center"/>
<ui:TextBox Grid.Row="1" Grid.Column="1" x:Name="IssueNumberTextBox" PlaceholderText="65432" Margin="10" Padding="8" PreviewTextInput="NumValidation" DataObject.Pasting="NumPasting"/>
<ui:TextBox Grid.Row="2" Grid.Column="1" x:Name="HoursTextBox" PlaceholderText="0.25" Margin="10" Padding="8" PreviewTextInput="FracValidation" DataObject.Pasting="FracPasting"/>
<ui:TextBox Grid.Row="3" Grid.Column="1" x:Name="MessageTextBox" PlaceholderText="Munka..." VerticalAlignment="Stretch" Margin="10" AcceptsReturn="True" TextWrapping="Wrap" Padding="8"/>
<ui:Button x:Name="sendButton" Appearance="Primary" Grid.Column="1" Grid.ColumnSpan="1" Grid.Row="4" Content="Send"
Margin="10" Padding="10" HorizontalAlignment="Stretch" Click="sendButton_Click">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Send24"/>
</ui:Button.Icon>
</ui:Button>
</Grid>
<Grid x:Name="MenuBlocks" Grid.Row="3" Grid.Column="2" Grid.ColumnSpan="3" Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="1*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="1*"/>
<RowDefinition Height="1*"/>
</Grid.RowDefinitions>
<ui:Button x:Name="closeButton" Grid.Row="0" Grid.Column="0" Margin="0, 0, 5, 5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Lezár" Click="CloseButtonClick" >
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Checkmark24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="browserButton" Grid.Row="0" Grid.Column="1" Margin="5, 0, 5, 5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Böngésző" Click="BrowserButtonClick" >
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Globe24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="newButton" Grid.Row="0" Grid.Column="2" Margin="5, 0, 0, 5" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Új jegy" >
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="New24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="apiButton" Grid.Row="1" Grid.Column="0" Margin="0, 5, 5, 0" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="API kulcs" Click="ApiButtonClicked">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Key24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="refreshButton" Grid.Row="1" Grid.Column="1" Margin="5, 5, 5, 0" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Frissítés" Click="RefreshButtonClick">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="ArrowRotateClockwise24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Button x:Name="fixButton" Grid.Row="1" Grid.Column="2" Margin="5, 5, 0, 0" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" Content="Fix" Click="FixButtonClick">
<ui:Button.Icon>
<ui:SymbolIcon Margin="0, 3, 0, 0" Symbol="Wrench24"/>
</ui:Button.Icon>
</ui:Button>
<ui:Flyout x:Name="apiFlyout" Grid.Row="1" Grid.Column="0">
<StackPanel Orientation="Vertical" Margin="10" Width="250">
<ui:Button x:Name="apiLinkButton" Content="API kulcs link" HorizontalAlignment="Stretch" Margin="10" Padding="10" Click="apiLinkButton_Click" />
<Label Content="API URL:" Margin="10" HorizontalAlignment="Stretch"/>
<ui:TextBox x:Name="apiUrlTextBox" HorizontalAlignment="Stretch" Margin="10" PlaceholderText="https://redmine.example.com" />
<Label Content="API kulcs:" Margin="10" HorizontalAlignment="Stretch"/>
<ui:PasswordBox x:Name="apiPasswordBox" HorizontalAlignment="Stretch" Margin="10" PasswordChar="●" PlaceholderText="" />
<ui:Button x:Name="apiSaveButton" Content="Csatlakozás" HorizontalAlignment="Stretch" Margin="10" Padding="10" Click="apiSaveButton_Click" />
</StackPanel>
</ui:Flyout>
</Grid>
<ui:TextBlock x:Name="statusTextBlock" Grid.Row="4" Grid.ColumnSpan="6" FontSize="8" Text="Staus: OK" Margin="10" />
</Grid>
</ui:FluentWindow>

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,91 @@
using System.Text;
using BlueMine.Redmine;
using System.IO;
using System.Security.Cryptography; // For encryption
using System.Text.Json;
namespace BlueMine
{
public class SettingsManager
{
// Save to: C:\Users\Username\AppData\Roaming\YourAppName\settings.json
private readonly string _filePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Blueberry",
"settings.json");
public RedmineConfig Load()
{
if (!File.Exists(_filePath))
return new RedmineConfig();
try
{
var json = File.ReadAllText(_filePath);
var config = JsonSerializer.Deserialize<RedmineConfig>(json);
if(config == null)
return new RedmineConfig();
// Decrypt the API Key if it exists
if (!string.IsNullOrEmpty(config.ApiKey))
{
config.ApiKey = Unprotect(config.ApiKey);
}
return config;
}
catch
{
// If file is corrupted, return default
return new RedmineConfig();
}
}
public void Save(RedmineConfig config)
{
// Create directory if it doesn't exist
Directory.CreateDirectory(Path.GetDirectoryName(_filePath) ?? throw new NullReferenceException("Config directory path creation failed."));
// Create a copy to encrypt so we don't mess up the runtime object
var copy = new RedmineConfig
{
RedmineUrl = config.RedmineUrl,
// Encrypt the key before saving
ApiKey = Protect(config.ApiKey),
ProjectCacheDuration = config.ProjectCacheDuration,
// ... copy other fields
};
var json = JsonSerializer.Serialize(copy, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_filePath, json);
}
// --- ENCRYPTION HELPERS (DPAPI) ---
// This encrypts data using the current user's Windows credentials.
// Only this user on this machine can decrypt it.
private string Protect(string clearText)
{
if (string.IsNullOrEmpty(clearText)) return "";
byte[] clearBytes = Encoding.UTF8.GetBytes(clearText);
byte[] encryptedBytes = ProtectedData.Protect(clearBytes, null, DataProtectionScope.CurrentUser);
return Convert.ToBase64String(encryptedBytes);
}
private string Unprotect(string encryptedText)
{
if (string.IsNullOrEmpty(encryptedText)) return "";
try
{
byte[] encryptedBytes = Convert.FromBase64String(encryptedText);
byte[] clearBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(clearBytes);
}
catch
{
return ""; // Decryption failed (maybe different user/machine)
}
}
}
}

BIN
BlueMine/bb.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

31
build.installer.ps1 Normal file
View 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