diff --git a/.editorconfig b/.editorconfig index 26fc913..2c28162 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,6 +13,11 @@ space_around_operators = true csharp_new_line_before_open_brace = none csharp_indent_case_contents = true csharp_language_version = latest +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true +indent_style = space +indent_size = 2 + [*.{xml,xaml,axaml}] xml.attribute.new_line = true diff --git a/.github/workflows/dotnet_innosetup.yml b/.github/workflows/dotnet_innosetup.yml index 4c59608..e73f79d 100644 --- a/.github/workflows/dotnet_innosetup.yml +++ b/.github/workflows/dotnet_innosetup.yml @@ -27,8 +27,8 @@ jobs: - name: Update version in iss file run: | - sed -i 's/#define AppVersion ".*"/#define AppVersion "${{ github.event.inputs.version }}"/' .winbuild/urlhandler.iss - + sed -i 's/#define AppVersion ".*"/#define AppVersion "${{ github.event.inputs.version }}"/' .winbuild/ChemLocalLink.iss + - name: Restore dependencies run: dotnet restore - name: Build @@ -38,12 +38,12 @@ jobs: - name: Publish project run: | dotnet publish -p:PublishSingleFile=true --self-contained false - ren "bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\urlhandler.exe" "ChemLocalLink.exe" + ren "bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\ChemLocalLink.exe" "ChemLocalLink.exe" - name: Build Installer uses: Minionguyjpro/Inno-Setup-Action@v1.2.2 with: - path: .winbuild/urlhandler.iss + path: .winbuild/ChemLocalLink.iss - name: Create GitHub Release id: create_release diff --git a/.gitignore b/.gitignore index 2cb152c..434da32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ bin/ obj/ .testapi/ -.config/ .vs/ .idea/ \ No newline at end of file diff --git a/.unixbuild/DEBREATE.dbp b/.unixbuild/DEBREATE.dbp deleted file mode 100644 index 1d88e2f..0000000 --- a/.unixbuild/DEBREATE.dbp +++ /dev/null @@ -1,143 +0,0 @@ -[DEBREATE-0.8-dev9] -<> -Package: ChemLocalLink -Version: 0.3 -Maintainer: mekkyz -Section: utils -Homepage: https://www.scc.kit.edu/ -Architecture: amd64 -Priority: optional -Pre-Depends: dotnet8 (>=8.0.4) -Description: ChemLocalLink - ChemLocalLink is a cross-platform desktop application designed to manage and process URLs specifically tailored for Chemotion interfaces. -<> -<> -1 -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Base.dll -> Avalonia.Base.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Controls.dll -> Avalonia.Controls.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.DesignerSupport.dll -> Avalonia.DesignerSupport.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Desktop.dll -> Avalonia.Desktop.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Dialogs.dll -> Avalonia.Dialogs.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Fonts.Inter.dll -> Avalonia.Fonts.Inter.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.FreeDesktop.dll -> Avalonia.FreeDesktop.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Markup.Xaml.dll -> Avalonia.Markup.Xaml.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Markup.dll -> Avalonia.Markup.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Metal.dll -> Avalonia.Metal.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.MicroCom.dll -> Avalonia.MicroCom.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Native.dll -> Avalonia.Native.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.OpenGL.dll -> Avalonia.OpenGL.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Remote.Protocol.dll -> Avalonia.Remote.Protocol.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Skia.dll -> Avalonia.Skia.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Themes.Fluent.dll -> Avalonia.Themes.Fluent.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.Win32.dll -> Avalonia.Win32.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.X11.dll -> Avalonia.X11.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Avalonia.dll -> Avalonia.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/ColorTextBlock.Avalonia.dll -> ColorTextBlock.Avalonia.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/CommunityToolkit.Mvvm.dll -> CommunityToolkit.Mvvm.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/DesktopNotifications.Avalonia.dll -> DesktopNotifications.Avalonia.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/DesktopNotifications.FreeDesktop.dll -> DesktopNotifications.FreeDesktop.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/DesktopNotifications.Windows.dll -> DesktopNotifications.Windows.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/DesktopNotifications.dll -> DesktopNotifications.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/DialogHost.Avalonia.dll -> DialogHost.Avalonia.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/HarfBuzzSharp.dll -> HarfBuzzSharp.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Markdown.Avalonia.dll -> Markdown.Avalonia.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/MicroCom.Runtime.dll -> MicroCom.Runtime.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Microsoft.Toolkit.Uwp.Notifications.dll -> Microsoft.Toolkit.Uwp.Notifications.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Microsoft.Win32.SystemEvents.dll -> Microsoft.Win32.SystemEvents.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/MsBox.Avalonia.dll -> MsBox.Avalonia.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Projektanker.Icons.Avalonia.FontAwesome.dll -> Projektanker.Icons.Avalonia.FontAwesome.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Projektanker.Icons.Avalonia.dll -> Projektanker.Icons.Avalonia.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/RestSharp.dll -> RestSharp.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/SkiaSharp.dll -> SkiaSharp.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/System.Drawing.Common.dll -> System.Drawing.Common.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/System.IO.Pipelines.dll -> System.IO.Pipelines.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/System.Reactive.dll -> System.Reactive.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Tmds.DBus.Protocol.dll -> Tmds.DBus.Protocol.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/Tmds.DBus.dll -> Tmds.DBus.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/icon.ico -> icon.ico -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/libHarfBuzzSharp.so -> libHarfBuzzSharp.so -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/libSkiaSharp.so -> libSkiaSharp.so -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/logo.png -> logo.png -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/ChemLocalLink* -> ChemLocalLink -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/ChemLocalLink.deps.json -> ChemLocalLink.deps.json -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/ChemLocalLink.dll -> ChemLocalLink.dll -> /usr/lib -/home/mostafa/Desktop/uh/ChemLocalLink/ChemLocalLink.runtimeconfig.json -> ChemLocalLink.runtimeconfig.json -> /usr/lib -<> -<> -<> -1 - -<> -<> -1 -#!/bin/bash -e - -ln -fs "/usr/lib/ChemLocalLink" "/usr/bin/ChemLocalLink" - -PROTOCOL="chemotion" -HANDLER_PATH="/usr/lib/ChemLocalLink" -DESKTOP_FILE="/usr/share/applications/${PROTOCOL}-handler.desktop" - -sudo bash -c "cat << EOF > '$DESKTOP_FILE' -[Desktop Entry] -Type=Application -Name=${PROTOCOL} Handler -Exec=$HANDLER_PATH %u -MimeType=x-scheme-handler/${PROTOCOL} -NoDisplay=true -EOF" - -sudo update-desktop-database "/usr/share/applications" - -echo "Protocol '${PROTOCOL}' has been associated with '${HANDLER_PATH}' system-wide." -<> -<> -1 -#!/bin/bash -e - -rm -f "/usr/bin/ChemLocalLink" -<> -<> -0 -<> -<> -<> -<>DEFAULT<> - -<> -<> -MIT License - -Copyright (c) 2024 KIT Scientific Computing Center (SCC) - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -<> -<> -1 -Name=ChemLocalLink -Version=1.0 -Exec=/usr/lib/ChemLocalLink -Icon=/usr/lib/icon.ico -Type=Application -Terminal=true -StartupNotify=true -Encoding=UTF-8 -Categories=Application; -<> \ No newline at end of file diff --git a/.winbuild/urlhandler.iss b/.winbuild/ChemLocalLink.iss similarity index 95% rename from .winbuild/urlhandler.iss rename to .winbuild/ChemLocalLink.iss index 991b259..9fd3d3f 100644 --- a/.winbuild/urlhandler.iss +++ b/.winbuild/ChemLocalLink.iss @@ -2,7 +2,7 @@ #include "CodeDependencies.iss" #define AppName "ChemLocalLink" -#define AppVersion "1.0.2" +#define AppVersion "2.0.0" #define Protocol "chemotion" [Setup] @@ -13,7 +13,7 @@ AppPublisherURL=https://www.scc.kit.edu/ AppVersion={#AppVersion} AppComments=ChemLocalLink AppContact=SDM, SCC, KIT -AppCopyright=Copyright (C) 2024 KIT Scientific Computing Center (SCC) +AppCopyright=Copyright (C) 2025 KIT Scientific Computing Center (SCC) DefaultDirName={commonpf64}\{#AppName} DefaultGroupName={#AppName} OutputDir=C:/app/build/. diff --git a/.winbuild/urlhandler.reg b/.winbuild/ChemLocalLink.reg similarity index 100% rename from .winbuild/urlhandler.reg rename to .winbuild/ChemLocalLink.reg diff --git a/App.axaml b/App.axaml index 5cdd37c..4ac9fa7 100644 --- a/App.axaml +++ b/App.axaml @@ -1,53 +1,90 @@ - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + diff --git a/App.axaml.cs b/App.axaml.cs index 961eef6..bddf4fc 100644 --- a/App.axaml.cs +++ b/App.axaml.cs @@ -1,45 +1,70 @@ -using System; +/// +/// Main application class +/// + +using System; using System.Diagnostics; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using urlhandler.Helpers; -using urlhandler.Views; -using urlhandler.ViewModels; +using ChemLocalLink.Services; +using ChemLocalLink.Utilities; +using ChemLocalLink.ViewModels; +using ChemLocalLink.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace ChemLocalLink; -namespace urlhandler; +public class App : Application +{ + private IServiceProvider? _serviceProvider; -public class App : Application { - public override void Initialize() { + public override void Initialize() + { AvaloniaXamlLoader.Load(this); } - public override void OnFrameworkInitializationCompleted() { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - var mw = new MainWindow(); - WindowHelper.MainWindowViewModel = new MainWindowViewModel(mw, desktop.Args ?? []); - mw.DataContext = WindowHelper.MainWindowViewModel; + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + // configure DI with the notification manager + var services = new ServiceCollection(); + services.AddApplicationServices(Program.NotificationManager); + _serviceProvider = services.BuildServiceProvider(); + + var mw = new MainWindowView(); + var viewModel = _serviceProvider.GetRequiredService(); + var windowService = _serviceProvider.GetRequiredService(); + + viewModel.Initialize(mw, desktop.Args ?? []); + + mw.DataContext = viewModel; desktop.Startup += DesktopOnStartup; desktop.MainWindow = mw; desktop.MainWindow.DataContext = mw.DataContext; - WindowHelper.MainWindow = mw; } base.OnFrameworkInitializationCompleted(); } - private void DesktopOnStartup(object? sender, ControlledApplicationLifetimeStartupEventArgs e) { + private void DesktopOnStartup(object? sender, ControlledApplicationLifetimeStartupEventArgs e) + { var currentProcess = Process.GetCurrentProcess(); - if (Process.GetProcessesByName("ChemLocalLink").Length > 0) { + if (Process.GetProcessesByName("ChemLocalLink").Length > 0) + { var processes = Process.GetProcessesByName("ChemLocalLink"); - foreach (Process process in processes) { - if (process.Id != currentProcess.Id) { - try { + foreach (Process process in processes) + { + if (process.Id != currentProcess.Id) + { + try + { process.Kill(); } - - catch (Exception) { - // ignored + catch (Exception ex) + { + Debug.WriteLine($"Failed to kill process {process.Id}: {ex.Message}"); } } } diff --git a/Assets/icon.ico b/Assets/icon.ico index 6072984..ebdf17c 100644 Binary files a/Assets/icon.ico and b/Assets/icon.ico differ diff --git a/Assets/icon.png b/Assets/icon.png new file mode 100644 index 0000000..508c193 Binary files /dev/null and b/Assets/icon.png differ diff --git a/Assets/logo.png b/Assets/logo.png deleted file mode 100644 index 5474f61..0000000 Binary files a/Assets/logo.png and /dev/null differ diff --git a/Behaviors/MenuItemBehavior.cs b/Behaviors/MenuItemBehavior.cs deleted file mode 100644 index 63e4e8e..0000000 --- a/Behaviors/MenuItemBehavior.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Xaml.Interactivity; -using CommunityToolkit.Mvvm.Input; -using urlhandler.ViewModels; - -namespace urlhandler.Behaviors; - -public class MenuItemBehavior : Behavior { - public static readonly StyledProperty ViewModelProperty = - AvaloniaProperty.Register(nameof(ViewModel)); - - public object ViewModel { - get => GetValue(ViewModelProperty); - set => SetValue(ViewModelProperty, value); - } - public static readonly StyledProperty CommandsProperty = - AvaloniaProperty.Register(nameof(IRelayCommand)); - - public object Commands { - get => GetValue(CommandsProperty); - set => SetValue(CommandsProperty, value); - } - - - protected override void OnAttached() { - base.OnAttached(); - if (AssociatedObject != null) { - AssociatedObject.Click += OnMenuItemClick!; - } - } - - protected override void OnDetaching() { - base.OnDetaching(); - if (AssociatedObject != null) { - AssociatedObject.Click -= OnMenuItemClick!; - } - } - - private async void OnMenuItemClick(object sender, Avalonia.Interactivity.RoutedEventArgs e) { - if (ViewModel != null) { - if (Commands != null) { - - var viewModel = ViewModel as MainWindowViewModel; - switch (Commands as string) { - - case "uploadndelete": - await viewModel!.UploadFiles("delete"); - break; - case "uploadnkeep": - await viewModel!.UploadFiles(""); - break; - case "deleteFile": - viewModel?.DeleteSelectedFile(); - break; - case "openFile": - viewModel?.OpenFile(); - break; - case "openDir": - viewModel?.OpenDownloadDirectory(); - break; - default: - break; - } - } - } - } -} diff --git a/urlhandler.csproj b/ChemLocalLink.csproj similarity index 57% rename from urlhandler.csproj rename to ChemLocalLink.csproj index 2b6bf5a..c18a98a 100644 --- a/urlhandler.csproj +++ b/ChemLocalLink.csproj @@ -1,6 +1,6 @@ - 1.0.1 + 2.0.0 WinExe enable true @@ -10,38 +10,45 @@ false latestmajor - net8.0-windows10.0.17763.0 + net8.0-windows10.0.17763.0 net8.0 - - + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + - + + - \ No newline at end of file + diff --git a/urlhandler.sln b/ChemLocalLink.sln similarity index 87% rename from urlhandler.sln rename to ChemLocalLink.sln index 5ac942d..76e96bf 100644 --- a/urlhandler.sln +++ b/ChemLocalLink.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.002.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "urlhandler", "urlhandler.csproj", "{5AFDCCEA-C49A-4494-A505-B42CF3A8CAF4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ChemLocalLink", "ChemLocalLink.csproj", "{5AFDCCEA-C49A-4494-A505-B42CF3A8CAF4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/Converters/DateTimeToStringConverter.cs b/Converters/DateTimeToStringConverter.cs deleted file mode 100644 index dd5045c..0000000 --- a/Converters/DateTimeToStringConverter.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Globalization; -using Avalonia.Data.Converters; -using Avalonia.Media; - -namespace urlhandler.Converters; - -public class DateTimeToStringConverter : IValueConverter { - public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - if (value is DateTime dateTime) { - return dateTime.ToString("dd/MM/yyyy h:mm tt", culture); - } - else if (value is long epoch) { - DateTime dateTimeFromEpoch = DateTimeOffset.FromUnixTimeSeconds(epoch).DateTime; - - DateTime localDateTime = dateTimeFromEpoch.ToLocalTime(); - - if (targetType == typeof(IBrush)) { - return DateTime.Now > localDateTime ? new SolidColorBrush(Color.FromRgb(231, 76, 60)) : new SolidColorBrush(Color.FromRgb(39, 174, 96)); - } - return DateTime.Now < localDateTime ? "File upload will expire on " + localDateTime.ToString("dd/MM/yyyy h:mm tt", culture) : "File upload expired."; - } - return value!; - } - - public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { - throw new NotImplementedException(); - } -} diff --git a/Converters/IndexToBooleanConverter.cs b/Converters/IndexToBooleanConverter.cs deleted file mode 100644 index a265ed6..0000000 --- a/Converters/IndexToBooleanConverter.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using Avalonia.Data.Converters; - -namespace urlhandler.Converters; -public class IndexToBooleanConverter : IValueConverter { - public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) { - if (value is int index) { - return index >= 0; - } - return false; - } - - public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) { - throw new NotSupportedException(); - } -} diff --git a/Extensions/StringExtension.cs b/Extensions/StringExtension.cs deleted file mode 100644 index 0006321..0000000 --- a/Extensions/StringExtension.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.IO; -using System.Security.Cryptography; -using System.Web; - -namespace urlhandler.Extensions; - -public static class StringExtension { - private const int scale = 1024; - private static readonly string[] orders = { "B", "kB", "MB", "GB", "TB" }; - - public static string FormatBytes(this long bytes) { - int orderIndex = 0; - decimal adjustedBytes = bytes; - - while (adjustedBytes >= scale && orderIndex < orders.Length - 1) { - adjustedBytes /= scale; - orderIndex++; - } - - // format the bytes with the appropriate unit - return $"{adjustedBytes:##.##} {orders[orderIndex]}"; - } - public static string FileCheckSum(this string filePath) { - using var stream = File.OpenRead(filePath); - using var sha256 = SHA256.Create(); - var checksum = BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", "").ToLower(); - return checksum; - } - - public static string? ParseUrl(this string inputUrl) { - try { - var uri = new Uri(inputUrl); - var parse = HttpUtility.ParseQueryString(uri.Query).Get("url"); - - return HttpUtility.UrlDecode(parse); - } - catch (Exception) { - return "invalid uri"; - } - } - - public static string? ExtractAuthToken(this string decodedUrl) { - var lastSlashIndex = decodedUrl.LastIndexOf('/'); - if (lastSlashIndex < 0 || lastSlashIndex == decodedUrl.Length - 1) { - return null; - } - return decodedUrl[(lastSlashIndex + 1)..]; - } -} diff --git a/Helpers/ApiHelper.cs b/Helpers/ApiHelper.cs deleted file mode 100644 index da419ed..0000000 --- a/Helpers/ApiHelper.cs +++ /dev/null @@ -1,14 +0,0 @@ -using urlhandler.Services; - -namespace urlhandler.Helpers; - -static class ApiHelper { - public static string? apiHost; - private static string? DownloadEndPoint = "api/v1/public/third_party_apps"; - private static string? UploadEndPoint = "api/v1/public/third_party_apps"; - private static string? TokenEndPoint = "api/v1/third_party_apps/token"; - public static string DownloadUrl(string token) => $"{apiHost}/{DownloadEndPoint}/{token}"; - public static string UploadUrl(string authToken) => $"{apiHost}/{UploadEndPoint}/{authToken}"; - public static string TokenUrl(string? attId, string? appId) => $"{apiHost}/{TokenEndPoint}?attID={attId}&appId={appId}"; - public static long TokenExp(string token) => new TokenService().GetTokenParameters(token).Expiration ?? 0; -} diff --git a/Helpers/FeedbackHelper.cs b/Helpers/FeedbackHelper.cs deleted file mode 100644 index f1b857d..0000000 --- a/Helpers/FeedbackHelper.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using DesktopNotifications; -using System.Diagnostics; -using System.IO; -using System.Threading.Tasks; -using urlhandler.ViewModels; -using System.Collections.Generic; - -namespace urlhandler.Helpers; - -public static class FeedbackHelper { - public static string FileKept = "Kept files can not be uploaded!"; - public static string DownloadFail = "Failed to download file"; - public static string DownloadSuccessful = "File downloaded successfully!"; - public static string Downloading = "Downloading File..."; - public static string FileAccessError = "File access error!"; - public static string FileNotEdited = "Not edited yet!"; - public static string InvalidUrl = "Invalid URL format!"; - public static string Minimize = "Auto-minimize!"; - public static string NetworkError = "Network error!"; - public static string NoDownloads = "No downloaded files"; - public static string TokenFail = "Failed to extract token!"; - public static string UnExpectedError = "Unexpected error!"; - public static string UploadFail = "Failed to upload file(s)"; - public static string UploadSuccessful = "File(s) uploaded successfully"; - - internal static async Task ShowNotificationAsync(string title, MainWindowViewModel mainWindowView) { - try { - if ((Environment.OSVersion.Platform == PlatformID.Win32NT && Environment.OSVersion.Version.Major >= 10) - || Environment.OSVersion.Platform == PlatformID.Unix) { - - var bodyMessages = new Dictionary - { - { FileKept, "Please download file again to edit and upload it." }, - { DownloadFail, "URL is false or expired. Try again with a different one." }, - { DownloadSuccessful, "Heroic action! Please continue like that!" }, - { UploadSuccessful, "Heroic action! Please continue like that!" }, - { FileAccessError, "Make sure the file exists and you have sufficient permissions." }, - { FileNotEdited, "File(s) not edited yet." }, - { InvalidUrl, "Ensure the URL matches the expected pattern." }, - { Minimize, "Minimized due to inactivity." }, - { NetworkError, "Please check your connection or contact support if the problem persists." }, - { NoDownloads, "There are no downloaded files at the moment." }, - { UploadFail, "URL is expired. You have to download it again using a new link." }, - { UnExpectedError, "Unexpected behavior, please report." } - }; - - string body = bodyMessages.ContainsKey(title) ? bodyMessages[title] : "Unexpected behavior, please report."; - - var nf = new Notification { - Title = title, - Body = body, - BodyImagePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "icon.ico") - }; - - await mainWindowView.notificationManager!.ShowNotification(nf); - } - } - catch (Exception ex) { - Debug.WriteLine($"Error showing notification: {ex.Message}"); - } - } -} diff --git a/Helpers/JsonHelper.cs b/Helpers/JsonHelper.cs deleted file mode 100644 index 6719eab..0000000 --- a/Helpers/JsonHelper.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Newtonsoft.Json; - -namespace urlhandler.Helpers; - -public static class JsonHelper { - public static void AppendJsonToFile(string path, object jsonObject) { - string json = JsonConvert.SerializeObject(jsonObject, Formatting.Indented); - - if (File.Exists(path) && !string.IsNullOrEmpty(File.ReadAllText(path))) { - string existingJson = File.ReadAllText(path); - - var existingEntries = JsonConvert.DeserializeObject>(existingJson); - - existingEntries.Add(jsonObject); - - json = JsonConvert.SerializeObject(existingEntries, Formatting.Indented); - } - else { - var newEntries = new List { jsonObject }; - - json = JsonConvert.SerializeObject(newEntries, Formatting.Indented); - } - - File.WriteAllText(path, json); - } - - public static async void WriteDataToAppData() { - var appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "ChemLocalLink"); - Directory.CreateDirectory(appDataPath); - var jsonFilePath = Path.Combine(appDataPath, "downloads.json"); - var data = JsonConvert.SerializeObject(WindowHelper.MainWindowViewModel!.DownloadedFiles); - await File.WriteAllTextAsync(jsonFilePath, data); - } -} diff --git a/Helpers/ProcessHelper.cs b/Helpers/ProcessHelper.cs deleted file mode 100644 index a91d9fa..0000000 --- a/Helpers/ProcessHelper.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; -using System.Web; -using urlhandler.Extensions; -using urlhandler.ViewModels; - -namespace urlhandler.Helpers; - -internal abstract class ProcessHelper { - public static async Task HandleProcess(MainWindowViewModel mainWindowView, string _url) { - try { - if (mainWindowView.IsAlreadyProcessing == false) { - - if (!Uri.TryCreate(mainWindowView.Url, UriKind.Absolute, out _)) { - mainWindowView.Status = FeedbackHelper.InvalidUrl; - await FeedbackHelper.ShowNotificationAsync(mainWindowView.Status, mainWindowView); - - return; - } - - if (mainWindowView.Url.ToLower().Contains("url=")) { - var uri = new Uri(mainWindowView.Url); - string? parm = HttpUtility.ParseQueryString(uri.Query).Get("url"); - if (!string.IsNullOrEmpty(parm)) { - mainWindowView.Url = parm; - _url = parm; - if (mainWindowView.Url != _url) { - mainWindowView.SelectedUrl = _url; - mainWindowView.Url = _url; - } - } - } - - if (!string.IsNullOrEmpty(_url) && mainWindowView.Url != _url) - mainWindowView.Url = _url; - var token = _url.ExtractAuthToken(); - var downloadedFile = await mainWindowView._downloadService.DownloadFile(mainWindowView, token!); - mainWindowView._filePath = downloadedFile?.filePath ?? null; - if (mainWindowView._filePath == null) { - mainWindowView.Status = FeedbackHelper.DownloadFail; - await FeedbackHelper.ShowNotificationAsync(mainWindowView.Status, mainWindowView); - return; - } - - await mainWindowView._fileService.ProcessFile(mainWindowView._filePath, mainWindowView, downloadedFile?.originalName ?? ""); - mainWindowView.Status = FeedbackHelper.DownloadSuccessful; - await FeedbackHelper.ShowNotificationAsync(mainWindowView.Status, mainWindowView); - - } - else { - mainWindowView.Status = FeedbackHelper.FileAccessError; - await FeedbackHelper.ShowNotificationAsync(mainWindowView.Status, mainWindowView); - } - } - - catch (HttpRequestException) { - mainWindowView.Status = FeedbackHelper.NetworkError; - await FeedbackHelper.ShowNotificationAsync(mainWindowView.Status, mainWindowView); - } - - catch (Exception ex) { - Console.WriteLine($"Error in Process method: {ex.Message}"); - throw; - } - } -} diff --git a/Helpers/ThemeHelper.cs b/Helpers/ThemeHelper.cs deleted file mode 100644 index cf8f619..0000000 --- a/Helpers/ThemeHelper.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.IO; - -public static class Theme { - private static string settingsFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ChemLocalLink", "theme.txt"); - - public static void SaveCurrentTheme(bool isDarkMode) { - var directory = Path.GetDirectoryName(settingsFilePath); - if (!Directory.Exists(directory)) { - Directory.CreateDirectory(directory!); - } - File.WriteAllText(settingsFilePath, isDarkMode ? "Dark" : "Light"); - } - - public static bool LoadCurrentTheme() { - if (File.Exists(settingsFilePath)) { - var theme = File.ReadAllText(settingsFilePath); - return theme == "Dark"; - } - return false; - } -} diff --git a/Helpers/WindowHelper.cs b/Helpers/WindowHelper.cs deleted file mode 100644 index 1053a05..0000000 --- a/Helpers/WindowHelper.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Threading; -using Newtonsoft.Json; -using urlhandler.Extensions; -using urlhandler.Models; -using urlhandler.Services; -using urlhandler.ViewModels; -using urlhandler.Views; - -namespace urlhandler.Helpers; - -public static class WindowHelper { - public static MainWindowViewModel? MainWindowViewModel { get; set; } - public static MainWindow? MainWindow { get; set; } - public static void Deactivate(MainWindowViewModel mainWindowView) { - var minimized = mainWindowView is { isMinimizedByIdleTimer: false, mainWindow.WindowState: WindowState.Minimized }; - mainWindowView.mainWindow.ShowInTaskbar = !minimized; - mainWindowView.isMinimizedByIdleTimer = minimized; - if (mainWindowView.idleTimer == null) return; - mainWindowView.idleTimer.IsEnabled = !minimized; - if (minimized) mainWindowView.idleTimer.Stop(); - else mainWindowView.idleTimer.Start(); - } - - public static void Load(MainWindowViewModel mainWindowView) { - new TrayService().InitializeTray(mainWindowView); - if (Environment.OSVersion.Platform == PlatformID.Win32NT && Environment.OSVersion.Version.Major >= 10 || - Environment.OSVersion.Platform == PlatformID.Unix) { - mainWindowView.notificationManager = Program.NotificationManager ?? throw new InvalidOperationException("Missing notification manager"); - } - - Task.Run(async () => { - try { - var appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ChemLocalLink"); - Directory.CreateDirectory(appDataPath); - var filePath = Path.Combine(appDataPath, "downloads.json"); - Console.WriteLine($"Checking for file at path: {filePath}"); - - if (File.Exists(filePath) && !string.IsNullOrEmpty(File.ReadAllText(filePath))) { - Console.WriteLine("File exists and is not empty"); - var data = File.ReadAllText(filePath); - var downloads = JsonConvert.DeserializeObject>(data); - - if (downloads.Count > 0) { - foreach (var download in downloads) { - if (File.Exists(download.FilePath)) { - if (download.IsKept && download.IsEdited) - download.IsEdited = false; - mainWindowView.DownloadedFiles.Insert(0, download); - } - } - - var newData = JsonConvert.SerializeObject(mainWindowView.DownloadedFiles.Reverse()); - File.WriteAllText(filePath, newData); - mainWindowView.HasFilesDownloaded = true; - } - else { - mainWindowView.HasFilesDownloaded = false; - } - } - else { - mainWindowView.HasFilesDownloaded = false; - Console.WriteLine("File does not exist or is empty"); - } - - if (mainWindowView.args.Length > 0) { - var parsedUrl = mainWindowView.args.First().ParseUrl(); - if (parsedUrl == null || parsedUrl == "invalid uri") { - mainWindowView.Status = FeedbackHelper.InvalidUrl; - await FeedbackHelper.ShowNotificationAsync(mainWindowView.Status, mainWindowView); - return; - } - - var authToken = parsedUrl.ExtractAuthToken(); - if (authToken == null) { - mainWindowView.Status = FeedbackHelper.TokenFail; - await FeedbackHelper.ShowNotificationAsync(mainWindowView.Status, mainWindowView); - return; - } - - mainWindowView.Url = parsedUrl; - await ProcessHelper.HandleProcess(mainWindowView, parsedUrl); - } - } - catch (Exception ex) { - Console.WriteLine($"Exception in Load method: {ex.Message}"); - } - }); - MinimizeWindowOnIdle(); - } - - private static void MinimizeWindowOnIdle() { - try { - var window = MainWindow!; - - var idleTimer = new DispatcherTimer { - Interval = TimeSpan.FromSeconds(300) - }; - idleTimer.Tick += async (sender, e) => { - var elapsedTime = DateTime.Now - MainWindowViewModel!.lastInteractionTime; - - if (!(elapsedTime.TotalSeconds > 300)) return; - MainWindowViewModel.isMinimizedByIdleTimer = true; - window.WindowState = WindowState.Minimized; - idleTimer.IsEnabled = false; - idleTimer.Stop(); - await FeedbackHelper.ShowNotificationAsync(FeedbackHelper.Minimize, MainWindowViewModel); - idleTimer.Start(); - window.PointerPressed += (sender, eventArgs) => ResetLastInteractionTime(MainWindowViewModel); - window.PointerMoved += (sender, eventArgs) => ResetLastInteractionTime(MainWindowViewModel); - window.KeyDown += (sender, eventArgs) => ResetLastInteractionTime(MainWindowViewModel); - MainWindowViewModel.lastInteractionTime = DateTime.Now; - }; - } - catch (Exception ex) { - Console.WriteLine($"Error in MinimizeWindowOnIdle: {ex.Message}"); - throw; - } - } - - private static void ResetLastInteractionTime(MainWindowViewModel mainWindowView) { - mainWindowView.lastInteractionTime = DateTime.Now; - - if (!mainWindowView.isMinimizedByIdleTimer) { - return; - } - - mainWindowView.mainWindow.WindowState = WindowState.Normal; - mainWindowView.mainWindow.ShowInTaskbar = true; - mainWindowView.isMinimizedByIdleTimer = false; - - if (mainWindowView.idleTimer != null) { - mainWindowView.idleTimer.IsEnabled = true; - mainWindowView.idleTimer.Start(); - } - else { - Console.WriteLine("Error: idleTimer is null."); - } - } - public static void ShowWindow() { - if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktopApp) return; - var mainWindow = desktopApp.MainWindow; - if (mainWindow == null) return; - mainWindow.Show(); - mainWindow.WindowState = WindowState.Normal; - mainWindow.ShowInTaskbar = true; - } -} diff --git a/LICENSE b/LICENSE index 51ffd7f..697423f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 KIT Scientific Computing Center (SCC) +Copyright (c) 2025 KIT Scientific Computing Center (SCC) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Models/DownloadModel.cs b/Models/DownloadModel.cs new file mode 100644 index 0000000..a082d73 --- /dev/null +++ b/Models/DownloadModel.cs @@ -0,0 +1,38 @@ +/// +/// Represents a downloaded file with tracking and metadata +/// + +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ChemLocalLink.Models; + +public partial class DownloadModel : ObservableObject +{ + // FileId to keep track of edited files in a list + public float FileId { get; set; } + public string FileName { get; set; } = string.Empty; + public string OriginalFileName { get; set; } = string.Empty; + public string FilePath { get; set; } = string.Empty; + public DateTime FileDownloadTimeStamp { get; set; } + public string Origin { get; set; } = string.Empty; + public string? Path { get; set; } + public string? Token { get; set; } + public string? SourceUrl { get; set; } + + [ObservableProperty] + private string? _fileSize; + public string? FileSumOnDownload { get; set; } + + [ObservableProperty] + private bool _isEdited; + + [ObservableProperty] + private bool _isKept; + + [ObservableProperty] + private bool _isCreated; + + [ObservableProperty] + private long _exp; +} diff --git a/Models/Downloads.cs b/Models/Downloads.cs deleted file mode 100644 index ce5d343..0000000 --- a/Models/Downloads.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using CommunityToolkit.Mvvm.ComponentModel; - -namespace urlhandler.Models; - -public partial class Downloads : ObservableObject { - // FileId to keep track of edited files in a list - public float FileId { get; set; } - public string FileName { get; set; } = string.Empty; - public string OriginalFileName { get; set; } = string.Empty; - public string FilePath { get; set; } = string.Empty; - public DateTime FileDownloadTimeStamp { get; set; } - [ObservableProperty] private string? _fileSize; - public string? FileSumOnDownload { get; set; } - - [ObservableProperty] private bool _isEdited; - [ObservableProperty] private bool _isKept; - [ObservableProperty] private long _exp; -} diff --git a/Models/FolderModel.cs b/Models/FolderModel.cs new file mode 100644 index 0000000..edbd575 --- /dev/null +++ b/Models/FolderModel.cs @@ -0,0 +1,88 @@ +/// +/// Hierarchical file organization supporting folder structures and origin grouping. +/// + +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ChemLocalLink.Models; + +public partial class OriginGroupModel : ObservableObject +{ + public string Origin { get; set; } = "Unknown"; + + [ObservableProperty] + private ObservableCollection files = []; + + [ObservableProperty] + private ObservableCollection folders = []; + + [ObservableProperty] + private ObservableCollection items = []; + + public OriginGroupModel() + { + Files.CollectionChanged += OnCollectionChanged; + Folders.CollectionChanged += OnCollectionChanged; + UpdateItems(); + } + + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + UpdateItems(); + } + + private void UpdateItems() + { + Items.Clear(); + foreach (var folder in Folders) + Items.Add(folder); + foreach (var file in Files) + Items.Add(file); + } + + public override string ToString() => Origin; +} + +public partial class FolderModel : ObservableObject +{ + public string Name { get; set; } = string.Empty; + public string FullPath { get; set; } = string.Empty; + + [ObservableProperty] + private ObservableCollection files = []; + + [ObservableProperty] + private ObservableCollection subFolders = []; + + [ObservableProperty] + private ObservableCollection items = []; + + public bool IsCollapsed { get; set; } = true; + + public FolderModel() + { + Files.CollectionChanged += OnCollectionChanged; + SubFolders.CollectionChanged += OnCollectionChanged; + UpdateItems(); + } + + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + UpdateItems(); + } + + private void UpdateItems() + { + Items.Clear(); + foreach (var subFolder in SubFolders) + Items.Add(subFolder); + foreach (var file in Files) + Items.Add(file); + } + + public override string ToString() => Name; +} diff --git a/Models/ProgressInfo.cs b/Models/ProgressInfo.cs deleted file mode 100644 index 080827f..0000000 --- a/Models/ProgressInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace urlhandler.Models; - -public class ProgressInfo(long bytesRead, long? totalBytesExpected, double percentage) { - public long BytesRead { get; } = bytesRead; - public long? TotalBytesExpected { get; } = totalBytesExpected; - public double Percentage { get; } = percentage; -} diff --git a/Models/ProgressModel.cs b/Models/ProgressModel.cs new file mode 100644 index 0000000..0a012c9 --- /dev/null +++ b/Models/ProgressModel.cs @@ -0,0 +1,12 @@ +/// +/// Represents progress of file transfer operations +/// + +namespace ChemLocalLink.Models; + +public class ProgressModel(long bytesRead, long? totalBytesExpected, double percentage) +{ + public long BytesRead { get; } = bytesRead; + public long? TotalBytesExpected { get; } = totalBytesExpected; + public double Percentage { get; } = percentage; +} diff --git a/Program.cs b/Program.cs index 63344f7..f4e03e9 100644 --- a/Program.cs +++ b/Program.cs @@ -1,34 +1,42 @@ -using System; -using System.Diagnostics; +/// +/// Main entry point +/// + +using System; using Avalonia; +using ChemLocalLink.Utilities; using DesktopNotifications; using Projektanker.Icons.Avalonia; using Projektanker.Icons.Avalonia.FontAwesome; -namespace urlhandler; +namespace ChemLocalLink; -internal class Program { - public static INotificationManager NotificationManager = null!; +internal class Program +{ + public static INotificationManager? NotificationManager = null!; - private static void Main(string[] args) { + private static void Main(string[] args) + { BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } - public static AppBuilder BuildAvaloniaApp() { + public static AppBuilder BuildAvaloniaApp() + { IconProvider.Current.Register(); if ( - Environment.OSVersion.Platform == PlatformID.Win32NT - && Environment.OSVersion.Version.Major >= 10 - || Environment.OSVersion.Platform == PlatformID.Unix - ) { + Environment.OSVersion.Platform == PlatformID.Win32NT && Environment.OSVersion.Version.Major >= 10 + || Environment.OSVersion.Platform == PlatformID.Unix + ) + { return AppBuilder - .Configure() - .UsePlatformDetect() - .SetupDesktopNotifications(out NotificationManager!) - .LogToTrace(); + .Configure() + .UsePlatformDetect() + .SetupDesktopNotifications(out NotificationManager!) + .LogToTrace(); } - else { + else + { return AppBuilder.Configure().UsePlatformDetect().LogToTrace(); } } diff --git a/README.md b/README.md index f62f540..8fd40ab 100644 --- a/README.md +++ b/README.md @@ -10,41 +10,72 @@ ChemLocalLink is a cross-platform application designed to manage and process Che ## Key Features - **URL Handling**: Handles chemotion-specific URLs to react with Chemotion. -- **History**: Maintains a list of downloaded Files for easy access and management. -- **File Handling**: Allows users to download Chemotion-related files, modify them, and then upload back -- **Notifications**: Integrated system tray and app window notifications to alert users about the status of their tasks. - -## Getting Started - -1. Clone the repository (`git clone https://github.com/Chemotion/ChemLocalLink.git`) - -2. Navigate to the project directory (`cd ChemLocalLink`) - -3. Build the project (`dotnet build`) - -## Usage - -To start the application, run after building the project. - -`dotnet run` +- **Structured Download Storage**: Persistent per‑origin folder layout: `///file.ext`. +- **Download & Edit Tracking**: Checksums detect local edits; edited items flagged automatically (periodic scan). +- **Folder Scan / Link External Files**: Scan action links any new, untracked files placed manually inside the download tree. +- **Upload Workflow (Keep or Delete)**: Upload edited files then either mark them as kept (retain on disk) or delete them (with recursive cleanup of now-empty folders). +- **Session Export / Import**: Portable `.chemlocallink` archive bundles transfer to another machine. +- **Desktop Notifications**: Progress + status notifications (download / upload / errors). +- **History & State Persistence**: `downloads.json` stored under application data, re‑hydrated on startup. + +## Directory & Data Layout +Application data base path: (OS ApplicationData)/`ChemLocalLink` + +Files created: +- `downloads.json` – persisted list of tracked downloads (including created / duplicated / scanned files). +- `config.json` – app config (currently only the resolved or overridden `DownloadDirectory`). +- `theme.json` (handled internally through `JsonDataService`). + +Default persistent download directory: +- Windows: `%USERPROFILE%/Documents/ChemLocalLink` +- macOS: `~/Documents/ChemLocalLink` +- Linux: `~/Documents/ChemLocalLink` if it exists, else `~/ChemLocalLink` + +Structure after processing a deep link (example): +``` +ChemLocalLink/ + example.chemotion.net/ + project/123/reactions/ + reaction_456.json +``` + +## Session Export / Import +Archive extension: `.chemlocallink` + +Contents: +``` +manifest.json // schemaVersion, appVersion, exportedAtUtc, fileCount +downloads.json // portable metadata (relative file names) +files/ // actual files, preserving subfolder structure +``` +Import rules: +- Skips entries whose checksum already exists. +- Recreates needed subdirectories relative to current download root. +- Adds non‑duplicate files to the top of history and rebuilds groups. + +## Typical Workflow +1. User clicks `chemotion://...` link (or pastes URL) in the app. +2. App resolves token + deep link path, downloads file to structured directory. +3. User edits file externally (double‑click to open). +4. App detects modification (checksum delta → IsEdited = true). +5. User uploads (single or bulk) +6. Optionally export session for transfer or backup. ## Contributing - -1. Fork the Project -2. Create your Feature Branch (`git checkout -b feature/FeatureName`) -3. Commit your Changes (`git commit -m 'Add some FeatureName'`) -4. Push to the Branch (`git push origin feature/FeatureName`) +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/your-feature` +3. Commit: `git commit -m "feat: add your feature"` +4. Push: `git push origin feature/your-feature` 5. Open a Pull Request -## License +Please open issues for bugs, feature requests, or clarifications. -This project is licensed under the MIT License - see the LICENSE file for details. +## License +MIT License – see `LICENSE` for full text. ## Contact +- Mostafa Mekky – [mekky@kit.edu](mailto:mekky@kit.edu) +- Issues: https://github.com/Chemotion/ChemLocalLink/issues -- Mostafa Mekky - [mekky@kit.edu](mailto:mekky@kit.edu) -- Create an issue here: [https://github.com/Chemotion/ChemLocalLink/issues](https://github.com/Chemotion/ChemLocalLink/issues) - -## Additional Resources - -[Chemotion official website](https://chemotion.net/) +## Related +- Chemotion: https://chemotion.net/ diff --git a/Services/ApiService.cs b/Services/ApiService.cs new file mode 100644 index 0000000..2582a63 --- /dev/null +++ b/Services/ApiService.cs @@ -0,0 +1,74 @@ +/// +/// Handles Chemotion API URLs and JWT token operations +/// + +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http; + +namespace ChemLocalLink.Services; + +public interface IApiService +{ + string ApiHost { get; set; } + string ApiEndpoint { get; set; } + string DownloadUrl(string token); + string UploadUrl(string authToken); + void SetFromUrl(string url); + JwtPayload GetTokenParameters(string token); + long TokenExp(string token); +} + +internal class ApiService : IApiService +{ + private readonly HttpClient _httpClient; + private string? _apiHost = ""; + private string? _apiEndpoint = ""; + + public string ApiHost + { + get => _apiHost ?? ""; + set => _apiHost = value; + } + + public string ApiEndpoint + { + get => _apiEndpoint ?? ""; + set => _apiEndpoint = value; + } + + public ApiService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public void SetFromUrl(string url) + { + var uri = new Uri(url); + ApiHost = $"{uri.Scheme}://{uri.Host}"; + + var path = uri.AbsolutePath; + var lastSlashIndex = path.LastIndexOf('/'); + if (lastSlashIndex > 0) + { + ApiEndpoint = path[1..lastSlashIndex]; + } + } + + public string DownloadUrl(string token) => $"{ApiHost}/{ApiEndpoint}/{token}"; + + public string UploadUrl(string authToken) => $"{ApiHost}/{ApiEndpoint}/{authToken}"; + + public JwtPayload GetTokenParameters(string url) + { + var handler = new JwtSecurityTokenHandler(); + var token = url[(url.LastIndexOf('/') + 1)..]; + + return handler.ReadToken(token) is not JwtSecurityToken jsonToken ? [] : jsonToken.Payload; + } + + public long TokenExp(string token) + { + return GetTokenParameters(token).Expiration ?? 0; + } +} diff --git a/Services/DownloadService.cs b/Services/DownloadService.cs deleted file mode 100644 index 2d324d3..0000000 --- a/Services/DownloadService.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using urlhandler.Extensions; -using urlhandler.Helpers; -using urlhandler.Models; -using urlhandler.ViewModels; - -namespace urlhandler.Services; - -internal interface IDownloadService { - Task<(string filePath, string originalName)?> DownloadFile(MainWindowViewModel mainWindowView, string token); -} - -internal class DownloadService : IDownloadService { - - public async Task<(string filePath, string originalName)?> DownloadFile(MainWindowViewModel mainWindowView, string authToken) { - try { - - var token = authToken.Length < 1 ? mainWindowView.Url![(mainWindowView.Url!.LastIndexOf('/') + 1)..] : authToken; - mainWindowView.AuthToken = token; - var url = new Uri(mainWindowView.Url!); - ApiHelper.apiHost = $"{url.Scheme}://{url.Host}"; - var downloadUrl = ApiHelper.DownloadUrl(token); - mainWindowView.Status = FeedbackHelper.Downloading; - var progress = new Progress(progressInfo => { - mainWindowView.Status = - $"Downloaded {progressInfo.BytesRead.FormatBytes()} out of {progressInfo.TotalBytesExpected?.FormatBytes() ?? "0"}."; - }); - - var (response, fileContentBytes) = await mainWindowView._httpClient.GetWithProgressAsync(downloadUrl, progress); - var headers = response.Content.Headers; - - var _headers = headers.ToImmutableDictionary(); - var contentDisposition = _headers["Content-Disposition"].FirstOrDefault(); - - if (!response.IsSuccessStatusCode || - contentDisposition == null || - !contentDisposition.Contains("filename")) { - mainWindowView.Status = FeedbackHelper.DownloadFail; - await FeedbackHelper.ShowNotificationAsync(mainWindowView.Status, mainWindowView); - - return null; - } - - var fileName = contentDisposition[(contentDisposition.IndexOf("=", StringComparison.Ordinal) + 1)..]; - var fileDir = Path.Combine(Path.GetTempPath(), "chemotion"); - Directory.CreateDirectory(fileDir); - - var filePath = Path.Combine(fileDir, fileName); - var originalName = Path.GetFileName(filePath); - var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); - var fileExtension = Path.GetExtension(fileName); - var counter = 1; - - while (File.Exists(filePath)) { - filePath = Path.Combine(fileDir, $"{fileNameWithoutExtension}-{counter}{fileExtension}"); - counter++; - } - - await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true); - await fileStream.WriteAsync(fileContentBytes.AsMemory(0, fileContentBytes.Length)); - return (filePath, originalName); - } - catch (Exception ex) { - var errorMessage = ex is IOException ? FeedbackHelper.FileAccessError : FeedbackHelper.UnExpectedError; - mainWindowView.Status = errorMessage; - await FeedbackHelper.ShowNotificationAsync(mainWindowView.Status, mainWindowView); - return null; - } - } -} diff --git a/Services/FileOpsService.cs b/Services/FileOpsService.cs new file mode 100644 index 0000000..d19d1bc --- /dev/null +++ b/Services/FileOpsService.cs @@ -0,0 +1,884 @@ +/// +/// Handles file download, upload, processing, and tracking operations +/// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Avalonia.Threading; +using ChemLocalLink.Models; +using ChemLocalLink.Utilities; +using ChemLocalLink.ViewModels; +using MsBox.Avalonia; + +namespace ChemLocalLink.Services; + +public interface IFileOpsService +{ + Task<(string filePath, string originalName)?> DownloadFile(MainWindowViewModel mainWindowView, string token); + Task ProcessFile(string? filePath, MainWindowViewModel mainWindowView, string originalName); + Task UploadEditedFiles(MainWindowViewModel mainWindowView, string role = ""); + Task DuplicateAndRenameFile( + MainWindowViewModel mainWindowView, + DownloadModel sourceFile, + string newFileName + ); + Task ScanFolderForNewFiles(MainWindowViewModel mainWindowView); +} + +internal class FileOpsService : IFileOpsService, IDisposable +{ + private readonly HttpClient _httpClient; + private readonly IApiService _apiService; + private readonly INotificationService _notificationService; + private readonly IJsonDataService _jsonDataService; + private readonly IPathService _pathService; + + public FileOpsService( + HttpClient httpClient, + IApiService apiService, + INotificationService notificationService, + IJsonDataService jsonDataService, + IPathService pathService + ) + { + _httpClient = httpClient; + _apiService = apiService; + _notificationService = notificationService; + _jsonDataService = jsonDataService; + _pathService = pathService; + } + + #region Download Operations + + public async Task<(string filePath, string originalName)?> DownloadFile( + MainWindowViewModel mainWindowView, + string authToken + ) + { + try + { + var token = authToken?.Length < 1 ? mainWindowView?.Url?[(mainWindowView.Url.LastIndexOf('/') + 1)..] : authToken; + + if (token == null || mainWindowView == null) + { + return null; + } + + mainWindowView.AuthToken = token; + if (mainWindowView.Url != null) + _apiService.SetFromUrl(mainWindowView.Url); + var downloadUrl = _apiService.DownloadUrl(token); + mainWindowView.Status = NotificationService.Messages.Downloading; + var progress = new Progress(progressInfo => + { + if (mainWindowView != null) + mainWindowView.Status = + $"Downloaded {progressInfo.BytesRead.FormatBytes()} out of {progressInfo.TotalBytesExpected?.FormatBytes() ?? "0"}."; + }); + + var (response, fileContentBytes) = await _httpClient.GetWithProgressAsync(downloadUrl, progress); + var headers = response.Content.Headers; + + var _headers = headers.ToImmutableDictionary(); + var contentDisposition = _headers["Content-Disposition"].FirstOrDefault(); + + if (!response.IsSuccessStatusCode || contentDisposition == null || !contentDisposition.Contains("filename")) + { + if (mainWindowView != null) + mainWindowView.Status = NotificationService.Messages.DownloadFail; + await _notificationService.ShowNotificationAsync( + mainWindowView?.Status ?? NotificationService.Messages.DownloadFail + ); + + return null; + } + + var fileName = contentDisposition[(contentDisposition.IndexOf("=", StringComparison.Ordinal) + 1)..]; + var baseDir = _pathService.GetDownloadDirectory(); + Directory.CreateDirectory(baseDir); + + var originHost = mainWindowView.Url.ExtractOriginHost() ?? "Unknown"; + var originDir = Path.Combine(baseDir, originHost); + + string fileDir; + if (!string.IsNullOrEmpty(mainWindowView.DeepLinkPath)) + { + var pathSegments = mainWindowView.DeepLinkPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + fileDir = originDir; + foreach (var segment in pathSegments) + { + fileDir = Path.Combine(fileDir, segment); + } + } + else + { + fileDir = originDir; + } + + Directory.CreateDirectory(fileDir); + + var filePath = Path.Combine(fileDir, fileName).NormalizePath(); + var originalName = Path.GetFileName(filePath); + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); + var fileExtension = Path.GetExtension(fileName); + var counter = 1; + + while (File.Exists(filePath)) + { + filePath = Path.Combine(fileDir, $"{fileNameWithoutExtension}-{counter}{fileExtension}").NormalizePath(); + counter++; + } + + await using var fileStream = new FileStream( + filePath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + 4096, + true + ); + if (fileContentBytes != null) + await fileStream.WriteAsync(fileContentBytes.AsMemory(0, fileContentBytes.Length)); + return (filePath, originalName); + } + catch (Exception ex) + { + var errorMessage = + ex is IOException ? NotificationService.Messages.FileAccessError : NotificationService.Messages.UnExpectedError; + if (mainWindowView != null) + mainWindowView.Status = errorMessage; + await _notificationService.ShowNotificationAsync(mainWindowView?.Status ?? errorMessage); + return null; + } + } + + #endregion + + #region File Processing + + public Task ProcessFile(string? filePath, MainWindowViewModel mainWindowView, string originalName) + { + try + { + if (filePath == null) + return Task.CompletedTask; + mainWindowView?._fileProcess?.Dispose(); + + if (mainWindowView != null) + { + mainWindowView._fileProcess = new Process + { + StartInfo = new ProcessStartInfo(filePath) { UseShellExecute = true }, + }; + } + else + { + throw new InvalidOperationException("MainWindowViewModel is not initialized."); + } + + mainWindowView._fileProcess.Start(); + + var random = new Random(2345); + + { + var id = + mainWindowView.DownloadedFiles?.Any() ?? false + ? mainWindowView.DownloadedFiles.Max(f => f.FileId) + 1 + : random.NextInt64(10000, 999999); + + // extract origin from URL + var originHost = mainWindowView.Url.ExtractOriginHost() ?? string.Empty; + var token = mainWindowView.AuthToken; // store per-file token + var exp = _apiService.TokenExp(mainWindowView.Url!); + + var download = new DownloadModel() + { + FileId = id, + FileName = Path.GetFileName(filePath), + OriginalFileName = originalName, + FilePath = filePath, + FileSumOnDownload = filePath.FileCheckSum(), + FileSize = new FileInfo(filePath).Length.FormatBytes(), + FileDownloadTimeStamp = File.GetLastWriteTime(filePath), + IsEdited = false, + IsCreated = false, + Exp = exp, + Origin = originHost, + Path = mainWindowView.DeepLinkPath, + Token = token, + SourceUrl = mainWindowView.Url + }; + + File.SetCreationTime(filePath, DateTime.Now); + + var appDataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ChemLocalLink" + ); + Directory.CreateDirectory(appDataPath); + var jsonFilePath = Path.Combine(appDataPath, "downloads.json"); + + var jsonObject = new + { + FileId = id, + FileName = Path.GetFileName(filePath), + OriginalFileName = originalName, + FilePath = filePath, + FileSumOnDownload = filePath.FileCheckSum(), + FileSize = new FileInfo(filePath).Length.FormatBytes(), + FileDownloadTimeStamp = File.GetLastWriteTime(filePath), + IsEdited = false, + IsCreated = false, + Exp = exp, + Origin = originHost, + Path = mainWindowView.DeepLinkPath, + Token = token, + SourceUrl = mainWindowView.Url + }; + + _jsonDataService.AppendJsonToFile(jsonFilePath, jsonObject); + + Dispatcher.UIThread.Post(() => + { + mainWindowView.DownloadedFiles?.Insert(0, download); + mainWindowView.HasFilesDownloaded = (mainWindowView.DownloadedFiles?.Count ?? 0) > 0; + mainWindowView.RebuildGroups(); + }); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error processing file: {ex.Message}"); + } + + return Task.CompletedTask; + } + + #endregion + + #region Upload Operations + + public async Task UploadEditedFiles(MainWindowViewModel mainWindowView, string role = "") + { + try + { + if (mainWindowView?.DownloadedFiles.Count == 0) + { + mainWindowView!.Status = NotificationService.Messages.NoDownloads; + await _notificationService.ShowNotificationAsync(mainWindowView.Status); + return false; + } + + if (mainWindowView?.SelectedDownloadedFileIndex > -1) + { + return await HandleSingleFileUpload(role, mainWindowView); + } + else + { + return await HandleMultipleFilesUpload(role, mainWindowView!); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error uploading files: {ex.Message}"); + return false; + } + finally + { + if (mainWindowView?.DownloadedFiles != null) + { + mainWindowView.HasFilesDownloaded = mainWindowView.DownloadedFiles.Count > 0; + } + } + } + + private async Task HandleSingleFileUpload(string role, MainWindowViewModel mainWindowViewModel) + { + var file = mainWindowViewModel.DownloadedFiles[mainWindowViewModel.SelectedDownloadedFileIndex]; + + if (file.IsKept) + { + mainWindowViewModel.Status = NotificationService.Messages.FileKept; + await _notificationService.ShowNotificationAsync(mainWindowViewModel.Status); + return false; + } + + var fileSumOnDisk = file.FilePath.FileCheckSum(); + var fileSumOnDownload = file.FileSumOnDownload; + + if (fileSumOnDisk.Equals(fileSumOnDownload)) + { + mainWindowViewModel.Status = NotificationService.Messages.FileNotEdited; + await _notificationService.ShowNotificationAsync(mainWindowViewModel.Status); + return false; + } + + return await AttemptUpload(file.FilePath, mainWindowViewModel, file.OriginalFileName, role); + } + + private async Task HandleMultipleFilesUpload(string role, MainWindowViewModel mainWindowViewModel) + { + var tempList = mainWindowViewModel.DownloadedFiles.ToList(); + var filesToRemove = new ObservableCollection(); + + foreach (var file in tempList) + { + if (file.IsKept) + { + mainWindowViewModel.Status = NotificationService.Messages.FileKept; + await _notificationService.ShowNotificationAsync(mainWindowViewModel.Status); + continue; + } + + var fileSumOnDisk = file.FilePath.FileCheckSum(); + var fileSumOnDownload = file.FileSumOnDownload; + + if (!fileSumOnDisk.Equals(fileSumOnDownload)) + { + var upload = await AttemptUpload(file.FilePath, mainWindowViewModel, file.OriginalFileName, role); + if (upload) + { + filesToRemove.Add(file); + } + } + } + + if (filesToRemove.Count == 0) + { + mainWindowViewModel.Status = NotificationService.Messages.FileNotEdited; + await _notificationService.ShowNotificationAsync(mainWindowViewModel.Status); + return false; + } + + return await HandleFileRemoval(filesToRemove, role, mainWindowViewModel); + } + + private async Task HandleFileRemoval( + ObservableCollection filesToRemove, + string role, + MainWindowViewModel mainWindowViewModel + ) + { + try + { + if (role == "delete") + { + var directoriesToCleanup = new List(); + + foreach (var file in filesToRemove) + { + mainWindowViewModel.DownloadedFiles.Remove(file); + if (File.Exists(file.FilePath)) + { + var fileDirectory = Path.GetDirectoryName(file.FilePath); + if (fileDirectory != null) + { + directoriesToCleanup.Add(fileDirectory); + } + File.Delete(file.FilePath); + } + } + + foreach (var directory in directoriesToCleanup.Distinct()) + { + CleanupEmptyDirectories(directory, mainWindowViewModel); + } + } + else + { + foreach (var file in filesToRemove) + { + var fileToKeep = mainWindowViewModel.DownloadedFiles[mainWindowViewModel.DownloadedFiles.IndexOf(file)]; + fileToKeep.IsEdited = false; + fileToKeep.IsKept = true; + } + } + + await _jsonDataService.WriteDataToAppData(mainWindowViewModel); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Error handling multiple files upload: {ex.Message}"); + await MessageBoxManager.GetMessageBoxStandard("Error", "Unexpected error occurred").ShowAsync(); + return false; + } + } + + private async Task AttemptUpload(string filePath, MainWindowViewModel mainView, string ogIsm, string role) + { + var file = mainView.DownloadedFiles.FirstOrDefault(f => f.FilePath == filePath); + if (file == null) + return false; + + var upload = await Upload(file, mainView, ogIsm); + if (!upload) + return false; + + if (role == "delete") + { + return DeleteFile(file, mainView); + } + else + { + return KeepFile(file, mainView); + } + } + + private bool DeleteFile(DownloadModel file, MainWindowViewModel mainWindowViewModel) + { + string? fileDirectory = null; + try + { + if (File.Exists(file.FilePath)) + { + fileDirectory = Path.GetDirectoryName(file.FilePath); + File.Delete(file.FilePath); + } + } + catch { } + + mainWindowViewModel.DownloadedFiles.RemoveAt(mainWindowViewModel.SelectedDownloadedFileIndex); + + if (fileDirectory != null) + { + CleanupEmptyDirectories(fileDirectory, mainWindowViewModel); + } + + _jsonDataService.WriteDataToAppData(mainWindowViewModel); + mainWindowViewModel.RebuildGroups(); + return true; + } + + private bool KeepFile(DownloadModel file, MainWindowViewModel mainWindowViewModel) + { + file.IsEdited = false; + file.IsKept = true; + _jsonDataService.WriteDataToAppData(mainWindowViewModel); + return true; + } + + public async Task Upload(string filePath, MainWindowViewModel mainView, string ogIsm = "") + { + // legacy path kept for backward compatibility + var model = mainView.DownloadedFiles.FirstOrDefault(f => f.FilePath == filePath); + if (model == null) + return false; + return await Upload(model, mainView, ogIsm); + } + + private async Task Upload(DownloadModel fileModel, MainWindowViewModel mainView, string ogIsm = "") + { + try + { + var token = fileModel.Token ?? mainView.AuthToken; + if (string.IsNullOrWhiteSpace(token)) + { + mainView.Status = NotificationService.Messages.UploadFail; + await _notificationService.ShowNotificationAsync(mainView.Status); + return false; + } + + // expiration check + var expUtc = DateTimeOffset.FromUnixTimeSeconds(fileModel.Exp).UtcDateTime; + if (DateTime.UtcNow > expUtc) + { + mainView.Status = NotificationService.Messages.UploadFail; // expired + await _notificationService.ShowNotificationAsync(mainView.Status); + return false; + } + + var normalizedPath = fileModel.FilePath.NormalizePath(); + if (string.IsNullOrEmpty(normalizedPath) || !File.Exists(normalizedPath)) + { + mainView.Status = NotificationService.Messages.FileAccessError; + await _notificationService.ShowNotificationAsync(mainView.Status); + return false; + } + + byte[] fileContentBytes = await File.ReadAllBytesAsync(normalizedPath); + var content = new MultipartFormDataContent + { + { new ByteArrayContent(fileContentBytes), "file", Path.GetFileName(normalizedPath) }, + }; + + var fileName = Path.GetFileName(normalizedPath); + content.Add(new StringContent(fileName), "attachmentName"); + + var fileSize = new FileInfo(normalizedPath).Length.FormatBytes(); + var progress = new Progress(prog => + { + var currentProgress = + prog.BytesRead > new FileInfo(normalizedPath).Length ? fileSize : prog.BytesRead.FormatBytes(); + mainView.FileUpDownProgressText = $"Uploaded {currentProgress} out of {fileSize}."; + mainView.Status = mainView.FileUpDownProgressText; + mainView.FileUpDownProgress = prog.Percentage; + if (prog.BytesRead >= new FileInfo(normalizedPath).Length) + { + mainView.Status = NotificationService.Messages.UploadSuccessful; + } + }); + + if (!string.IsNullOrWhiteSpace(fileModel.SourceUrl)) + { + _apiService.SetFromUrl(fileModel.SourceUrl); + } + + var response = await _httpClient.PostWithProgressAsync( + _apiService.UploadUrl(token), + content, + progress, + isUpload: true + ); + + if (response.IsSuccessStatusCode) + { + mainView.Status = NotificationService.Messages.UploadSuccessful; + await _notificationService.ShowNotificationAsync(mainView.Status); + return true; + } + else + { + mainView.Status = NotificationService.Messages.UploadFail; + await _notificationService.ShowNotificationAsync(mainView.Status); + return false; + } + } + catch (Exception ex) + { + Console.WriteLine($"Error uploading file: {ex.Message}"); + mainView.Status = NotificationService.Messages.UploadFail; + await _notificationService.ShowNotificationAsync(mainView.Status); + return false; + } + } + + private void CleanupEmptyDirectories(string startDirectory, MainWindowViewModel mainWindowViewModel) + { + try + { + var downloadRoot = _pathService.GetDownloadDirectory(); + var currentDir = startDirectory; + + while ( + !string.IsNullOrEmpty(currentDir) + && currentDir.Length > downloadRoot.Length + && currentDir.StartsWith(downloadRoot, StringComparison.OrdinalIgnoreCase) + ) + { + try + { + if (Directory.Exists(currentDir)) + { + var files = Directory.GetFiles(currentDir); + var subdirs = Directory.GetDirectories(currentDir); + + if (files.Length == 0 && subdirs.Length == 0) + { + Directory.Delete(currentDir); + currentDir = Path.GetDirectoryName(currentDir); + } + else + { + break; + } + } + else + { + currentDir = Path.GetDirectoryName(currentDir); + } + } + catch + { + break; + } + } + } + catch { } + } + + #endregion + + #region File Creation and Scanning + + public async Task DuplicateAndRenameFile( + MainWindowViewModel mainWindowView, + DownloadModel sourceFile, + string newFileName + ) + { + try + { + if (string.IsNullOrWhiteSpace(newFileName) || newFileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + await _notificationService.ShowNotificationAsync("Invalid filename provided."); + return null; + } + + var sourceDir = Path.GetDirectoryName(sourceFile.FilePath); + if (sourceDir == null || !File.Exists(sourceFile.FilePath)) + { + await _notificationService.ShowNotificationAsync("Source file not found."); + return null; + } + + var newFilePath = Path.Combine(sourceDir, newFileName); + + var counter = 1; + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(newFileName); + var fileExtension = Path.GetExtension(newFileName); + + while (File.Exists(newFilePath)) + { + var numberedFileName = $"{fileNameWithoutExtension}-{counter}{fileExtension}"; + newFilePath = Path.Combine(sourceDir, numberedFileName); + counter++; + } + + File.Copy(sourceFile.FilePath, newFilePath); + + var random = new Random(2345); + var newId = + mainWindowView.DownloadedFiles?.Any() ?? false + ? mainWindowView.DownloadedFiles.Max(f => f.FileId) + 1 + : random.NextInt64(10000, 999999); + + var newDownload = new DownloadModel() + { + FileId = newId, + FileName = Path.GetFileName(newFilePath), + OriginalFileName = sourceFile.OriginalFileName, + FilePath = newFilePath, + FileSumOnDownload = newFilePath.FileCheckSum(), + FileSize = new FileInfo(newFilePath).Length.FormatBytes(), + FileDownloadTimeStamp = DateTime.Now, + IsEdited = false, + IsCreated = true, + IsKept = false, + Exp = sourceFile.Exp, + Origin = sourceFile.Origin, + Path = sourceFile.Path, + Token = sourceFile.Token, + SourceUrl = sourceFile.SourceUrl + }; + + var appDataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ChemLocalLink" + ); + Directory.CreateDirectory(appDataPath); + var jsonFilePath = Path.Combine(appDataPath, "downloads.json"); + + var jsonObject = new + { + FileId = newId, + FileName = newDownload.FileName, + OriginalFileName = newDownload.OriginalFileName, + FilePath = newDownload.FilePath, + FileSumOnDownload = newDownload.FileSumOnDownload, + FileSize = newDownload.FileSize, + FileDownloadTimeStamp = newDownload.FileDownloadTimeStamp, + IsEdited = newDownload.IsEdited, + IsCreated = newDownload.IsCreated, + IsKept = newDownload.IsKept, + Exp = newDownload.Exp, + Origin = newDownload.Origin, + Path = newDownload.Path, + Token = newDownload.Token, + SourceUrl = newDownload.SourceUrl + }; + + _jsonDataService.AppendJsonToFile(jsonFilePath, jsonObject); + + Dispatcher.UIThread.Post(() => + { + mainWindowView.DownloadedFiles?.Insert(0, newDownload); + mainWindowView.HasFilesDownloaded = (mainWindowView.DownloadedFiles?.Count ?? 0) > 0; + mainWindowView.RebuildGroups(); + }); + + await _notificationService.ShowNotificationAsync($"File duplicated as '{Path.GetFileName(newFilePath)}'"); + return newDownload; + } + catch (Exception ex) + { + Console.WriteLine($"Error duplicating file: {ex.Message}"); + await _notificationService.ShowNotificationAsync("Error duplicating file."); + return null; + } + } + + public async Task ScanFolderForNewFiles(MainWindowViewModel mainWindowView) + { + try + { + var downloadRoot = _pathService.GetDownloadDirectory(); + if (!Directory.Exists(downloadRoot)) + { + await _notificationService.ShowNotificationAsync("Download directory not found."); + return; + } + + var newFilesAdded = 0; + var filesRemoved = 0; + var allFiles = Directory.GetFiles(downloadRoot, "*.*", SearchOption.AllDirectories); + var trackedFilePaths = + mainWindowView.DownloadedFiles?.Select(f => f.FilePath).ToHashSet() ?? new HashSet(); + + var filesToRemove = new List(); + if (mainWindowView.DownloadedFiles != null) + { + foreach (var trackedFile in mainWindowView.DownloadedFiles) + { + if (!File.Exists(trackedFile.FilePath)) + { + filesToRemove.Add(trackedFile); + } + } + } + + foreach (var fileToRemove in filesToRemove) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + mainWindowView.DownloadedFiles?.Remove(fileToRemove); + mainWindowView.HasFilesDownloaded = (mainWindowView.DownloadedFiles?.Count ?? 0) > 0; + }); + filesRemoved++; + } + + trackedFilePaths = mainWindowView.DownloadedFiles?.Select(f => f.FilePath).ToHashSet() ?? new HashSet(); + + foreach (var filePath in allFiles) + { + if (!trackedFilePaths.Contains(filePath)) + { + var directory = Path.GetDirectoryName(filePath); + var mostRecentFile = mainWindowView + .DownloadedFiles?.Where(f => Path.GetDirectoryName(f.FilePath) == directory) + .OrderByDescending(f => f.FileDownloadTimeStamp) + .FirstOrDefault(); + + if (mostRecentFile != null) + { + await LinkUntracked(filePath, mostRecentFile, mainWindowView); + newFilesAdded++; + } + } + } + + await _jsonDataService.WriteDataToAppData(mainWindowView); + + Dispatcher.UIThread.Post(() => + { + mainWindowView.RebuildGroups(); + }); + + var statusMessage = $"Scan complete. {newFilesAdded} new files added"; + if (filesRemoved > 0) + { + statusMessage += $", {filesRemoved} missing files removed"; + } + statusMessage += "."; + + await _notificationService.ShowNotificationAsync(statusMessage); + } + catch (Exception ex) + { + Console.WriteLine($"Error scanning folder: {ex.Message}"); + await _notificationService.ShowNotificationAsync("Error scanning folder for new files."); + } + } + + private async Task LinkUntracked(string filePath, DownloadModel parentFile, MainWindowViewModel mainWindowView) + { + try + { + var random = new Random(2345); + var newId = + mainWindowView.DownloadedFiles?.Any() ?? false + ? mainWindowView.DownloadedFiles.Max(f => f.FileId) + 1 + : random.NextInt64(10000, 999999); + + var newDownload = new DownloadModel() + { + FileId = newId, + FileName = Path.GetFileName(filePath), + OriginalFileName = Path.GetFileName(filePath), + FilePath = filePath, + FileSumOnDownload = filePath.FileCheckSum(), + FileSize = new FileInfo(filePath).Length.FormatBytes(), + FileDownloadTimeStamp = File.GetLastWriteTime(filePath), + IsEdited = false, + IsCreated = true, // Mark as created since it appeared outside the app + IsKept = false, + Exp = parentFile.Exp, + Origin = parentFile.Origin, + Path = parentFile.Path, + Token = parentFile.Token, + SourceUrl = parentFile.SourceUrl + }; + + var appDataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ChemLocalLink" + ); + Directory.CreateDirectory(appDataPath); + var jsonFilePath = Path.Combine(appDataPath, "downloads.json"); + + var jsonObject = new + { + FileId = newId, + FileName = newDownload.FileName, + OriginalFileName = newDownload.OriginalFileName, + FilePath = newDownload.FilePath, + FileSumOnDownload = newDownload.FileSumOnDownload, + FileSize = newDownload.FileSize, + FileDownloadTimeStamp = newDownload.FileDownloadTimeStamp, + IsEdited = newDownload.IsEdited, + IsCreated = newDownload.IsCreated, + IsKept = newDownload.IsKept, + Exp = newDownload.Exp, + Origin = newDownload.Origin, + Path = newDownload.Path, + Token = newDownload.Token, + SourceUrl = newDownload.SourceUrl + }; + + _jsonDataService.AppendJsonToFile(jsonFilePath, jsonObject); + + await Dispatcher.UIThread.InvokeAsync(() => + { + mainWindowView.DownloadedFiles?.Insert(0, newDownload); + mainWindowView.HasFilesDownloaded = (mainWindowView.DownloadedFiles?.Count ?? 0) > 0; + mainWindowView.RebuildGroups(); + }); + } + catch (Exception ex) + { + Console.WriteLine($"Error linking untracked file: {ex.Message}"); + } + } + + #endregion + + #region Helper Methods + + #endregion + + #region IDisposable Implementation + + public void Dispose() + { + // Cleanup resources if needed + } + + #endregion +} diff --git a/Services/FileService.cs b/Services/FileService.cs deleted file mode 100644 index 9b95ef8..0000000 --- a/Services/FileService.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using urlhandler.Extensions; -using urlhandler.Models; -using urlhandler.ViewModels; -using urlhandler.Helpers; - -namespace urlhandler.Services; - -internal interface IFileService { - Task ProcessFile(string? filePath, MainWindowViewModel mainWindowView, string originalName); -} - -internal class FileService : IFileService { - public Task ProcessFile(string? filePath, MainWindowViewModel mainWindowView, string originalName) { - try { - if (filePath == null) return Task.CompletedTask; - WindowHelper.MainWindowViewModel?._fileProcess?.Dispose(); - - if (WindowHelper.MainWindowViewModel != null) { - WindowHelper.MainWindowViewModel._fileProcess = new Process { - StartInfo = new ProcessStartInfo(filePath) { - UseShellExecute = true - } - }; - } - else { - throw new InvalidOperationException("MainWindowViewModel is not initialized."); - } - - WindowHelper.MainWindowViewModel._fileProcess.Start(); - - var random = new Random(2345); - - { - var id = WindowHelper.MainWindowViewModel.DownloadedFiles?.Any() ?? false - ? WindowHelper.MainWindowViewModel.DownloadedFiles.Max(f => f.FileId) + 1 - : random.NextInt64(10000, 999999); - - var download = new Downloads() { - FileId = id, - FileName = Path.GetFileName(filePath), - OriginalFileName = originalName, - FilePath = filePath, - FileSumOnDownload = filePath.FileCheckSum(), - FileSize = new FileInfo(filePath).Length.FormatBytes(), - FileDownloadTimeStamp = File.GetLastWriteTime(filePath), - IsEdited = false, - Exp = ApiHelper.TokenExp(mainWindowView.Url!) - }; - - File.SetCreationTime(filePath, DateTime.Now); - - var appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ChemLocalLink"); - Directory.CreateDirectory(appDataPath); - var jsonFilePath = Path.Combine(appDataPath, "downloads.json"); - - var jsonObject = new { - FileId = id, - FileName = Path.GetFileName(filePath), - OriginalFileName = originalName, - FilePath = filePath, - FileSumOnDownload = filePath.FileCheckSum(), - FileSize = new FileInfo(filePath).Length.FormatBytes(), - FileDownloadTimeStamp = File.GetLastWriteTime(filePath), - IsEdited = false, - Exp = ApiHelper.TokenExp(mainWindowView.Url!) - }; - - JsonHelper.AppendJsonToFile(jsonFilePath, jsonObject); - - WindowHelper.MainWindowViewModel.DownloadedFiles?.Insert(0, download); - } - - WindowHelper.MainWindowViewModel.HasFilesDownloaded = mainWindowView.DownloadedFiles.Count > 0; - } - catch (Exception ex) { - Console.WriteLine($"Error processing file: {ex.Message}"); - } - - return Task.CompletedTask; - } -} diff --git a/Services/JsonDataService.cs b/Services/JsonDataService.cs new file mode 100644 index 0000000..dd64615 --- /dev/null +++ b/Services/JsonDataService.cs @@ -0,0 +1,107 @@ +/// +/// Handles data persistence for JSON files, theme settings, and app configuration +/// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ChemLocalLink.ViewModels; +using Newtonsoft.Json; + +namespace ChemLocalLink.Services; + +public interface IJsonDataService +{ + void AppendJsonToFile(string path, object jsonObject); + Task WriteDataToAppData(MainWindowViewModel mainWindowViewModel); + void SaveCurrentTheme(bool isDarkMode); + bool LoadCurrentTheme(); +} + +internal class JsonDataService : IJsonDataService +{ + private readonly string _appDataPath; + private readonly string _themeFilePath; + + public JsonDataService() + { + _appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ChemLocalLink"); + _themeFilePath = Path.Combine(_appDataPath, "theme.txt"); + } + + public void AppendJsonToFile(string path, object jsonObject) + { + string json = JsonConvert.SerializeObject(jsonObject, Formatting.Indented); + + if (File.Exists(path) && !string.IsNullOrEmpty(File.ReadAllText(path))) + { + string existingJson = File.ReadAllText(path); + + var existingEntries = JsonConvert.DeserializeObject>(existingJson); + + existingEntries.Add(jsonObject); + + json = JsonConvert.SerializeObject(existingEntries, Formatting.Indented); + } + else + { + var newEntries = new List { jsonObject }; + + json = JsonConvert.SerializeObject(newEntries, Formatting.Indented); + } + + var tmp = path + ".tmp"; + File.WriteAllText(tmp, json); + File.Move(tmp, path, true); + } + + public async Task WriteDataToAppData(MainWindowViewModel mainWindowViewModel) + { + Directory.CreateDirectory(_appDataPath); + var jsonFilePath = Path.Combine(_appDataPath, "downloads.json"); + var data = JsonConvert.SerializeObject( + mainWindowViewModel.DownloadedFiles.Select(d => new + { + d.FileId, + d.FileName, + d.OriginalFileName, + d.FilePath, + d.FileSumOnDownload, + d.FileSize, + d.FileDownloadTimeStamp, + d.IsEdited, + d.IsCreated, + d.Exp, + d.Origin, + d.Path, + d.Token, + d.SourceUrl, + d.IsKept + }), + Formatting.Indented + ); + var tmp = jsonFilePath + ".tmp"; + await File.WriteAllTextAsync(tmp, data); + if (File.Exists(jsonFilePath)) + File.Delete(jsonFilePath); + File.Move(tmp, jsonFilePath); + } + + public void SaveCurrentTheme(bool isDarkMode) + { + Directory.CreateDirectory(_appDataPath); + File.WriteAllText(_themeFilePath, isDarkMode ? "Dark" : "Light"); + } + + public bool LoadCurrentTheme() + { + if (File.Exists(_themeFilePath)) + { + var theme = File.ReadAllText(_themeFilePath); + return theme == "Dark"; + } + return false; + } +} diff --git a/Services/NotificationService.cs b/Services/NotificationService.cs new file mode 100644 index 0000000..96e7593 --- /dev/null +++ b/Services/NotificationService.cs @@ -0,0 +1,96 @@ +/// +/// Manages system notifications across platforms +/// + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using DesktopNotifications; + +namespace ChemLocalLink.Services; + +public interface INotificationService +{ + Task ShowNotificationAsync(string title, string? body = null); +} + +internal class NotificationService : INotificationService +{ + private readonly INotificationManager? _notificationManager; + + // Constants for feedback messages + public static class Messages + { + public const string FileKept = "Kept files can not be uploaded!"; + public const string DownloadFail = "Failed to download file"; + public const string DownloadSuccessful = "File downloaded successfully!"; + public const string Downloading = "Downloading File..."; + public const string FileAccessError = "File access error!"; + public const string FileNotEdited = "Not edited yet!"; + public const string InvalidUrl = "Invalid URL format!"; + public const string Minimize = "Auto-minimize!"; + public const string NetworkError = "Network error!"; + public const string NoDownloads = "No downloaded files"; + public const string TokenFail = "Failed to extract token!"; + public const string UnExpectedError = "Unexpected error!"; + public const string UploadFail = "Failed to upload file(s)"; + public const string UploadSuccessful = "File(s) uploaded successfully"; + } + + private static readonly Dictionary _bodyMessages = + new() + { + { Messages.FileKept, "Please download file again to edit and upload it." }, + { Messages.DownloadFail, "URL is false or expired. Try again with a different one." }, + { Messages.DownloadSuccessful, "Heroic action! Please continue like that!" }, + { Messages.UploadSuccessful, "Heroic action! Please continue like that!" }, + { Messages.FileAccessError, "Make sure the file exists and you have sufficient permissions." }, + { Messages.FileNotEdited, "File(s) not edited yet." }, + { Messages.InvalidUrl, "Ensure the URL matches the expected pattern." }, + { Messages.Minimize, "Minimized due to inactivity." }, + { Messages.NetworkError, "Please check your connection or contact support if the problem persists." }, + { Messages.NoDownloads, "There are no downloaded files at the moment." }, + { Messages.UploadFail, "URL is expired. You have to download it again using a new link." }, + { Messages.UnExpectedError, "Unexpected behavior, please report." }, + }; + + public NotificationService(INotificationManager? notificationManager = null) + { + _notificationManager = notificationManager; + } + + public async Task ShowNotificationAsync(string title, string? body = null) + { + try + { + if (_notificationManager == null) + { + Debug.WriteLine($"Notification: {title} - {body ?? "No body"}"); + return; + } + + if ( + (Environment.OSVersion.Platform == PlatformID.Win32NT && Environment.OSVersion.Version.Major >= 10) + || Environment.OSVersion.Platform == PlatformID.Unix + ) + { + var resolvedBody = body ?? (_bodyMessages.ContainsKey(title) ? _bodyMessages[title] : ""); + + var notification = new Notification + { + Title = title, + Body = resolvedBody, + BodyImagePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "icon.ico"), + }; + + await _notificationManager.ShowNotification(notification); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error showing notification: {ex.Message}"); + } + } +} diff --git a/Services/PathService.cs b/Services/PathService.cs new file mode 100644 index 0000000..c6b804b --- /dev/null +++ b/Services/PathService.cs @@ -0,0 +1,172 @@ +/// +/// Provides cross-platform paths for downloads and configuration, and handles migration. +/// +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using ChemLocalLink.Models; +using Newtonsoft.Json; + +namespace ChemLocalLink.Services; + +public interface IPathService +{ + string GetDownloadDirectory(); + void EnsureDownloadDirectory(); + string GetConfigPath(); + AppConfig LoadConfig(); + void SaveConfig(AppConfig config); + void MigrateFromLegacyTempDir(IList existingDownloads); +} + +public class AppConfig +{ + public int Version { get; set; } = 1; + public string? DownloadDirectory { get; set; } +} + +internal class PathService : IPathService +{ + private readonly string _appDataPath; + private readonly string _configFilePath; + private const string LegacyTempFolderName = "chemotion"; // legacy temp download folder + + public PathService() + { + _appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ChemLocalLink"); + Directory.CreateDirectory(_appDataPath); + _configFilePath = Path.Combine(_appDataPath, "config.json"); + } + + public string GetConfigPath() => _configFilePath; + + public AppConfig LoadConfig() + { + try + { + if (File.Exists(_configFilePath)) + { + var txt = File.ReadAllText(_configFilePath); + if (!string.IsNullOrWhiteSpace(txt)) + { + return JsonConvert.DeserializeObject(txt) ?? new AppConfig(); + } + } + } + catch { } + return new AppConfig(); + } + + public void SaveConfig(AppConfig config) + { + Directory.CreateDirectory(_appDataPath); + File.WriteAllText(_configFilePath, JsonConvert.SerializeObject(config, Formatting.Indented)); + } + + public string GetDownloadDirectory() + { + var cfg = LoadConfig(); + if (!string.IsNullOrWhiteSpace(cfg.DownloadDirectory)) + { + return cfg.DownloadDirectory!; + } + + // Determine default + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + string defaultDir; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + defaultDir = Path.Combine(documents, "ChemLocalLink"); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // macOS documents folder + defaultDir = Path.Combine(documents, "ChemLocalLink"); + } + else // Linux / others + { + if (!string.IsNullOrWhiteSpace(documents) && Directory.Exists(documents)) + { + defaultDir = Path.Combine(documents, "ChemLocalLink"); + } + else + { + defaultDir = Path.Combine(home, "ChemLocalLink"); + } + } + + cfg.DownloadDirectory = defaultDir; + SaveConfig(cfg); + return defaultDir; + } + + public void EnsureDownloadDirectory() + { + var dir = GetDownloadDirectory(); + Directory.CreateDirectory(dir); + } + + public void MigrateFromLegacyTempDir(IList existingDownloads) + { + try + { + if (existingDownloads == null || existingDownloads.Count == 0) + return; + var legacyDir = Path.Combine(Path.GetTempPath(), LegacyTempFolderName); + if (!Directory.Exists(legacyDir)) + return; + + var targetDir = GetDownloadDirectory(); + Directory.CreateDirectory(targetDir); + + var changed = false; + foreach (var d in existingDownloads.ToList()) + { + try + { + if (string.IsNullOrWhiteSpace(d.FilePath)) + continue; + var full = d.FilePath; + if (!File.Exists(full)) + continue; // already removed + + // only migrate if inside legacy folder + if (!Path.GetFullPath(full).StartsWith(Path.GetFullPath(legacyDir), StringComparison.OrdinalIgnoreCase)) + continue; + + var fileName = Path.GetFileName(full); + var destPath = Path.Combine(targetDir, fileName); + var nameNoExt = Path.GetFileNameWithoutExtension(fileName); + var ext = Path.GetExtension(fileName); + int counter = 1; + while (File.Exists(destPath)) + { + destPath = Path.Combine(targetDir, $"{nameNoExt}-{counter}{ext}"); + counter++; + } + + try + { + File.Move(full, destPath); + } + catch + { + // fallback to copy if move fails + File.Copy(full, destPath, overwrite: false); + } + + d.FilePath = destPath; + changed = true; + } + catch { } + } + + if (changed) { } + } + catch { } + } +} diff --git a/Services/SessionService.cs b/Services/SessionService.cs new file mode 100644 index 0000000..4b6aaa4 --- /dev/null +++ b/Services/SessionService.cs @@ -0,0 +1,265 @@ +/// +/// Handles exporting and importing session data (.chemlocallink) +/// +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using ChemLocalLink.Models; +using ChemLocalLink.Utilities; +using ChemLocalLink.ViewModels; +using Newtonsoft.Json; + +namespace ChemLocalLink.Services; + +public interface ISessionService +{ + Task ExportSessionAsync(MainWindowViewModel vm, string targetArchivePath); + Task ImportSessionAsync(MainWindowViewModel vm, string sourceArchivePath); +} + +internal class SessionService : ISessionService +{ + private readonly IPathService _pathService; + private readonly IJsonDataService _jsonDataService; + + public SessionService(IPathService pathService, IJsonDataService jsonDataService) + { + _pathService = pathService; + _jsonDataService = jsonDataService; + } + + private record Manifest(int schemaVersion, string appVersion, DateTime exportedAtUtc, int fileCount); + + public async Task ExportSessionAsync(MainWindowViewModel vm, string targetArchivePath) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(targetArchivePath)!); + if (File.Exists(targetArchivePath)) + File.Delete(targetArchivePath); + + using var zip = ZipFile.Open(targetArchivePath, ZipArchiveMode.Create); + + var downloads = vm.DownloadedFiles.ToList(); + var existingFiles = downloads + .Where(d => !string.IsNullOrWhiteSpace(d.FilePath) && File.Exists(d.FilePath)) + .Where(d => !d.FilePath.EndsWith("~")) // exclude backup files + .ToList(); + + // manifest + var manifest = new Manifest(1, vm.AppVersion, DateTime.UtcNow, existingFiles.Count); + var manifestEntry = zip.CreateEntry("manifest.json"); + using (var ms = manifestEntry.Open()) + using (var sw = new StreamWriter(ms)) + sw.Write(JsonConvert.SerializeObject(manifest, Formatting.Indented)); + + // prepare portable downloads.json + var portableList = existingFiles + .Select(d => new PortableDownload + { + FileId = d.FileId, + FileName = d.FileName, + OriginalFileName = d.OriginalFileName, + FileRelativeName = !string.IsNullOrWhiteSpace(d.Path) + ? $"{d.Path}/{Path.GetFileName(d.FilePath)}" + : Path.GetFileName(d.FilePath), + FileDownloadTimeStamp = d.FileDownloadTimeStamp, + FileSize = d.FileSize, + FileSumOnDownload = d.FileSumOnDownload, + IsEdited = d.IsEdited, + IsCreated = d.IsCreated, + IsKept = d.IsKept, + Exp = d.Exp, + Origin = d.Origin, + Path = d.Path, + Token = d.Token, + SourceUrl = d.SourceUrl + }) + .ToList(); + + var downloadsEntry = zip.CreateEntry("downloads.json"); + using (var ms = downloadsEntry.Open()) + using (var sw = new StreamWriter(ms)) + sw.Write(JsonConvert.SerializeObject(portableList, Formatting.Indented)); + + // add files + foreach (var d in existingFiles) + { + try + { + var fileName = Path.GetFileName(d.FilePath); + string entryPath; + + if (!string.IsNullOrWhiteSpace(d.Path)) + { + // preserve folder structure + entryPath = $"files/{d.Path}/{fileName}"; + } + else + { + entryPath = $"files/{fileName}"; + } + + var entry = zip.CreateEntry(entryPath, CompressionLevel.Optimal); + using var fs = File.OpenRead(d.FilePath); + using var es = entry.Open(); + await fs.CopyToAsync(es); + } + catch { } + } + + return true; + } + catch + { + return false; + } + } + + private class PortableDownload + { + public float FileId { get; set; } + public string FileName { get; set; } = string.Empty; + public string OriginalFileName { get; set; } = string.Empty; + public string FileRelativeName { get; set; } = string.Empty; + public DateTime FileDownloadTimeStamp { get; set; } + public string? FileSize { get; set; } + public string? FileSumOnDownload { get; set; } + public bool IsEdited { get; set; } + public bool IsCreated { get; set; } + public bool IsKept { get; set; } + public long Exp { get; set; } + public string Origin { get; set; } = string.Empty; + public string? Path { get; set; } + public string? Token { get; set; } + public string? SourceUrl { get; set; } + } + + public async Task ImportSessionAsync(MainWindowViewModel vm, string sourceArchivePath) + { + if (!File.Exists(sourceArchivePath)) + return false; + + try + { + using var zip = ZipFile.OpenRead(sourceArchivePath); + + var manifestEntry = zip.GetEntry("manifest.json"); + var downloadsEntry = zip.GetEntry("downloads.json"); + if (manifestEntry == null || downloadsEntry == null) + return false; + + Manifest? manifest; + using (var ms = manifestEntry.Open()) + using (var sr = new StreamReader(ms)) + { + manifest = JsonConvert.DeserializeObject(sr.ReadToEnd()); + } + if (manifest == null || manifest.schemaVersion != 1) + return false; + + List? portableDownloads; + using (var ms = downloadsEntry.Open()) + using (var sr = new StreamReader(ms)) + { + portableDownloads = JsonConvert.DeserializeObject>(sr.ReadToEnd()); + } + if (portableDownloads == null) + return false; + + var targetDir = _pathService.GetDownloadDirectory(); + Directory.CreateDirectory(targetDir); + + var existingChecksums = vm + .DownloadedFiles.Where(d => d.FileSumOnDownload != null) + .Select(d => d.FileSumOnDownload) + .ToHashSet(); + var rand = new Random(); + var added = false; + + foreach (var pd in portableDownloads) + { + try + { + if (!string.IsNullOrWhiteSpace(pd.FileSumOnDownload) && existingChecksums.Contains(pd.FileSumOnDownload)) + continue; // skip duplicate by checksum + + var sourceFileEntry = zip.GetEntry($"files/{pd.FileRelativeName}"); + if (sourceFileEntry == null) + continue; // missing file + + // Create the target path including folder structure + string destPath; + if (!string.IsNullOrWhiteSpace(pd.Path)) + { + var folderPath = Path.Combine(targetDir, pd.Path); + Directory.CreateDirectory(folderPath); + destPath = Path.Combine(folderPath, Path.GetFileName(pd.FileRelativeName)).NormalizePath(); + } + else + { + destPath = Path.Combine(targetDir, Path.GetFileName(pd.FileRelativeName)).NormalizePath(); + } + + var nameNoExt = Path.GetFileNameWithoutExtension(destPath); + var ext = Path.GetExtension(destPath); + var baseDir = Path.GetDirectoryName(destPath)!; + int counter = 1; + while (File.Exists(destPath)) + { + destPath = Path.Combine(baseDir, $"{nameNoExt}-{counter}{ext}").NormalizePath(); + counter++; + } + + using (var es = sourceFileEntry.Open()) + using (var fs = File.Create(destPath)) + await es.CopyToAsync(fs); + + var finalFileName = Path.GetFileName(destPath); + + // create new model + var model = new DownloadModel + { + FileId = pd.FileId == 0 ? rand.NextInt64(10000, 999999) : pd.FileId, + FileName = finalFileName, + OriginalFileName = pd.OriginalFileName, + FilePath = destPath, + FileDownloadTimeStamp = pd.FileDownloadTimeStamp, + FileSize = new FileInfo(destPath).Length.FormatBytes(), + FileSumOnDownload = pd.FileSumOnDownload, + IsEdited = false, + IsCreated = pd.IsCreated, + IsKept = pd.IsKept, + Exp = pd.Exp, + Origin = pd.Origin, + Path = pd.Path, + Token = pd.Token, + SourceUrl = pd.SourceUrl + }; + + vm.DownloadedFiles.Insert(0, model); + if (!string.IsNullOrWhiteSpace(model.FileSumOnDownload)) + existingChecksums.Add(model.FileSumOnDownload); + added = true; + } + catch { } + } + + if (added) + { + vm.HasFilesDownloaded = vm.DownloadedFiles.Count > 0; + vm.RebuildGroups(); + await _jsonDataService.WriteDataToAppData(vm); + } + + return added; + } + catch + { + return false; + } + } +} diff --git a/Services/TokenService.cs b/Services/TokenService.cs deleted file mode 100644 index 5ab7c32..0000000 --- a/Services/TokenService.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.IdentityModel.Tokens.Jwt; -using System.Net.Http; -using System.Threading.Tasks; -using urlhandler.Helpers; -using urlhandler.ViewModels; - -namespace urlhandler.Services; - -internal interface ITokenService { - Task FetchAuthToken(MainWindowViewModel mainWindowView); - JwtPayload GetTokenParameters(string token); -} -internal class TokenService : ITokenService { - public async Task FetchAuthToken(MainWindowViewModel mainWindowView) { - try { - var tokenParameters = GetTokenParameters(mainWindowView.Url!); - if (true) { - var response = await mainWindowView._httpClient.GetAsync(ApiHelper.TokenUrl(tokenParameters["attID"].ToString(), tokenParameters["appID"].ToString())); - - if (response.IsSuccessStatusCode) { - var content = await response.Content.ReadAsStringAsync(); - - if (!string.IsNullOrEmpty(content)) { - mainWindowView.AuthToken = content; - } - else { - throw new InvalidOperationException("Failed to parse auth token from response content."); - } - } - else { - throw new HttpRequestException($"Failed to fetch auth token. Status code: {response.StatusCode}"); - } - } - } - - catch (HttpRequestException ex) { - Console.WriteLine($"Error fetching auth token: {ex.Message}"); - throw; - } - - catch (Exception ex) { - Console.WriteLine($"Error fetching auth token: {ex.Message}"); - throw; - } - } - - public JwtPayload GetTokenParameters(string url) { - var handler = new JwtSecurityTokenHandler(); - return handler.ReadToken(url[(url.LastIndexOf('/') + 1)..]) is not JwtSecurityToken jsonToken ? [] : jsonToken.Payload; - } -} diff --git a/Services/TrayService.cs b/Services/TrayService.cs index 6c51e42..fd318f5 100644 --- a/Services/TrayService.cs +++ b/Services/TrayService.cs @@ -1,66 +1,103 @@ -using Avalonia; +/// +/// Manages system tray icon, context menu, and user interactions +/// + +using System; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Platform; using Avalonia.Threading; +using ChemLocalLink.ViewModels; using CommunityToolkit.Mvvm.Input; -using urlhandler.Helpers; -using urlhandler.ViewModels; -using System; -namespace urlhandler.Services; +namespace ChemLocalLink.Services; -public interface ITrayService { - void InitializeTray(MainWindowViewModel viewModel); +public interface ITrayService +{ + void InitializeTray(MainWindowViewModel viewModel, IWindowService windowService); } -public class TrayService : ITrayService { - private TrayIcon? _notifyIcon; - private MainWindowViewModel? _mainWindowViewModel; +public class TrayService : ITrayService +{ + private TrayIcon? _notifyIcon; + private MainWindowViewModel? _mainWindowViewModel; + + public TrayService() { } - public void InitializeTray(MainWindowViewModel viewModel) { - _mainWindowViewModel = viewModel; + public void InitializeTray(MainWindowViewModel viewModel, IWindowService windowService) + { + _mainWindowViewModel = viewModel; - var _trayMenu = new NativeMenu { - new NativeMenuItem { - Header = "Open app", - Command = new RelayCommand(() => { - Dispatcher.UIThread.Invoke(() => { - _mainWindowViewModel!.mainWindow.WindowState = WindowState.Normal; - _mainWindowViewModel.mainWindow.ShowInTaskbar = true; - }); - }) - }, - new NativeMenuItem { - Header = "Upload all edited files & delete locally", - Command = new AsyncRelayCommand(async () => await _mainWindowViewModel!.UploadFiles("delete")) - }, - new NativeMenuItem { - Header = "Upload all edited files & keep locally", - Command = new AsyncRelayCommand(async () => await _mainWindowViewModel!.UploadFiles("")) - }, - new NativeMenuItem { - Header = "Open files folder", - Command = new RelayCommand(() => _mainWindowViewModel!.OpenDownloadDirectory()) - }, - new NativeMenuItem { - Header = "Exit app", - Command = new RelayCommand(() => { - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopApp) { - desktopApp.Shutdown(); - } - }) + var _trayMenu = new NativeMenu + { + new NativeMenuItem + { + Header = "Open app", + Command = new RelayCommand(() => + { + Dispatcher.UIThread.Invoke(() => + { + windowService.ShowWindow(); + }); + }) + }, + new NativeMenuItem + { + Header = "Upload all edited files & delete locally", + Command = new AsyncRelayCommand(async () => await _mainWindowViewModel!.UploadFiles("delete")) + }, + new NativeMenuItem + { + Header = "Upload all edited files & keep locally", + Command = new AsyncRelayCommand(async () => await _mainWindowViewModel!.UploadFiles("")) + }, + new NativeMenuItemSeparator(), + new NativeMenuItem + { + Header = "Reload app", + Command = new RelayCommand(() => + { + Dispatcher.UIThread.Post(() => + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName ?? "", + UseShellExecute = false, + }, + }; + process.Start(); + desktop.Shutdown(0); } - }; + }); + }), + }, + new NativeMenuItem + { + Header = "Exit app", + Command = new RelayCommand(() => + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(0); + } + }), + }, + }; - _notifyIcon = new TrayIcon { - Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://urlhandler/Assets/icon.ico"))), - IsVisible = true, - ToolTipText = "ChemLocalLink", - Menu = _trayMenu - }; + _notifyIcon = new TrayIcon + { + Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://ChemLocalLink/Assets/icon.ico"))), + IsVisible = true, + ToolTipText = "ChemLocalLink", + Menu = _trayMenu, + }; - // wire up events - _notifyIcon.Clicked += (sender, e) => WindowHelper.ShowWindow(); - } + // wire up events + _notifyIcon.Clicked += (sender, e) => windowService.ShowWindow(); + } } diff --git a/Services/UploadService.cs b/Services/UploadService.cs deleted file mode 100644 index ca24fda..0000000 --- a/Services/UploadService.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using MsBox.Avalonia; -using urlhandler.Models; -using urlhandler.ViewModels; -using urlhandler.Extensions; -using urlhandler.Helpers; - -namespace urlhandler.Services; - -public interface IUploadService { - Task UploadEditedFiles(string role = ""); -} - -internal class UploadService : IUploadService { - public async Task UploadEditedFiles(string role = "") { - try { - var mainWindowViewModel = WindowHelper.MainWindowViewModel; - if (mainWindowViewModel?.DownloadedFiles.Count == 0) { - mainWindowViewModel!.Status = FeedbackHelper.NoDownloads; - await FeedbackHelper.ShowNotificationAsync(mainWindowViewModel.Status, mainWindowViewModel); - return false; - } - - if (mainWindowViewModel?.SelectedDownloadedFileIndex > -1) { - return await HandleSingleFileUpload(role, mainWindowViewModel); - } - else { - return await HandleMultipleFilesUpload(role, mainWindowViewModel!); - } - } - catch (Exception) { - return false; - } - finally { - var mainWindowViewModel = WindowHelper.MainWindowViewModel; - if (mainWindowViewModel?.DownloadedFiles != null) { - mainWindowViewModel.HasFilesDownloaded = mainWindowViewModel.DownloadedFiles.Count > 0; - } - } - } - - private async Task HandleSingleFileUpload(string role, MainWindowViewModel mainWindowViewModel) { - var file = mainWindowViewModel.DownloadedFiles[mainWindowViewModel.SelectedDownloadedFileIndex]; - - if (file.IsKept) { - mainWindowViewModel.Status = FeedbackHelper.FileKept; - await FeedbackHelper.ShowNotificationAsync(mainWindowViewModel.Status, mainWindowViewModel); - return false; - } - - var fileSumOnDisk = file.FilePath.FileCheckSum(); - var fileSumOnDownload = file.FileSumOnDownload; - - if (fileSumOnDisk.Equals(fileSumOnDownload)) { - mainWindowViewModel.Status = FeedbackHelper.FileNotEdited; - await FeedbackHelper.ShowNotificationAsync(mainWindowViewModel.Status, mainWindowViewModel); - return false; - } - - return await AttemptUpload(file.FilePath, mainWindowViewModel, file.OriginalFileName, role); - } - - private async Task HandleMultipleFilesUpload(string role, MainWindowViewModel mainWindowViewModel) { - var tempList = mainWindowViewModel.DownloadedFiles.ToList(); - var filesToRemove = new ObservableCollection(); - - foreach (var file in tempList) { - if (file.IsKept) { - mainWindowViewModel.Status = FeedbackHelper.FileKept; - await FeedbackHelper.ShowNotificationAsync(mainWindowViewModel.Status, mainWindowViewModel); - continue; - } - - var fileSumOnDisk = file.FilePath.FileCheckSum(); - var fileSumOnDownload = file.FileSumOnDownload; - - if (!fileSumOnDisk.Equals(fileSumOnDownload)) { - var upload = await AttemptUpload(file.FilePath, mainWindowViewModel, file.OriginalFileName, role); - if (upload) { - filesToRemove.Add(file); - } - } - } - - if (filesToRemove.Count == 0) { - mainWindowViewModel.Status = FeedbackHelper.FileNotEdited; - await FeedbackHelper.ShowNotificationAsync(mainWindowViewModel.Status, mainWindowViewModel); - return false; - } - - return await HandleFileRemoval(filesToRemove, role, mainWindowViewModel); - } - - private static bool DeleteFile(Downloads file, MainWindowViewModel mainWindowViewModel) { - File.Delete(file.FilePath); - mainWindowViewModel.DownloadedFiles.RemoveAt(mainWindowViewModel.SelectedDownloadedFileIndex); - JsonHelper.WriteDataToAppData(); - return true; - } - - private static bool KeepFile(Downloads file, MainWindowViewModel mainWindowViewModel) { - file.IsEdited = false; - file.IsKept = true; - JsonHelper.WriteDataToAppData(); - return true; - } - - private async Task HandleFileRemoval(ObservableCollection filesToRemove, string role, MainWindowViewModel mainWindowViewModel) { - try { - if (role == "delete") { - foreach (var file in filesToRemove) { - mainWindowViewModel.DownloadedFiles.Remove(file); - if (File.Exists(file.FilePath)) { - File.Delete(file.FilePath); - } - } - } - else { - foreach (var file in filesToRemove) { - var fileToKeep = mainWindowViewModel.DownloadedFiles[mainWindowViewModel.DownloadedFiles.IndexOf(file)]; - fileToKeep.IsEdited = false; - fileToKeep.IsKept = true; - } - } - - JsonHelper.WriteDataToAppData(); - return true; - } - catch (Exception) { - await MessageBoxManager.GetMessageBoxStandard("Error", "Unexpected error occurred").ShowAsync(); - return false; - } - } - - private async Task AttemptUpload(string filePath, MainWindowViewModel mainView, string ogIsm, string role) { - var upload = await Upload(filePath, mainView, ogIsm); - if (!upload) return false; - - var file = mainView.DownloadedFiles.FirstOrDefault(f => f.FilePath == filePath); - if (file == null) return false; - - if (role == "delete") { - return DeleteFile(file, mainView); - } else { - return KeepFile(file, mainView); - } - } - - public async Task Upload(string filePath, MainWindowViewModel mainView, string ogIsm = "") { - try { - if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { - mainView.Status = FeedbackHelper.FileAccessError; - await FeedbackHelper.ShowNotificationAsync(WindowHelper.MainWindowViewModel?.Status!, mainView); - return false; - } - - byte[] fileContentBytes = await File.ReadAllBytesAsync(filePath); - var content = new MultipartFormDataContent { - { new ByteArrayContent(fileContentBytes), "file", Path.GetFileName(filePath) } - }; - - var fileName = !string.IsNullOrEmpty(ogIsm) ? ogIsm : new FileInfo(filePath).Name; - content.Add(new StringContent(fileName), "attachmentName"); - - var fileSize = new FileInfo(filePath).Length.FormatBytes(); - var progress = new Progress(prog => { - var currentProgress = prog.BytesRead > new FileInfo(filePath).Length - ? fileSize - : prog.BytesRead.FormatBytes(); - mainView.FileUpDownProgressText = $"Uploaded {currentProgress} out of {fileSize}."; - mainView.Status = mainView.FileUpDownProgressText; - mainView.FileUpDownProgress = prog.Percentage; - if (prog.BytesRead >= new FileInfo(filePath).Length) { - mainView.Status = FeedbackHelper.UploadSuccessful; - } - }); - - var response = await mainView._httpClient.PostWithProgressAsync(ApiHelper.UploadUrl(mainView.AuthToken), - content, progress, isUpload: true); - - if (response.IsSuccessStatusCode) { - mainView.Status = FeedbackHelper.UploadSuccessful; - await FeedbackHelper.ShowNotificationAsync(WindowHelper.MainWindowViewModel?.Status!, mainView); - return true; - } - else { - mainView.Status = FeedbackHelper.UploadFail; - await FeedbackHelper.ShowNotificationAsync(WindowHelper.MainWindowViewModel?.Status!, mainView); - return false; - } - } - catch (Exception) { - mainView.Status = FeedbackHelper.UploadFail; - await FeedbackHelper.ShowNotificationAsync(WindowHelper.MainWindowViewModel?.Status!, mainView); - return false; - } - } -} diff --git a/Services/WindowService.cs b/Services/WindowService.cs new file mode 100644 index 0000000..01428d8 --- /dev/null +++ b/Services/WindowService.cs @@ -0,0 +1,276 @@ +/// +/// Manages window state, startup, and user interaction tracking +/// + +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using ChemLocalLink.Models; +using ChemLocalLink.Utilities; +using ChemLocalLink.ViewModels; +using ChemLocalLink.Views; +using Newtonsoft.Json; + +namespace ChemLocalLink.Services; + +public interface IWindowService +{ + MainWindowViewModel? MainWindowViewModel { get; set; } + MainWindowView? MainWindow { get; set; } + void Deactivate(MainWindowViewModel mainWindowView); + void Load(MainWindowViewModel mainWindowView); + void ShowWindow(); +} + +public class WindowService : IWindowService +{ + private readonly ITrayService _trayService; + private readonly INotificationService _notificationService; + private readonly IWorkflowService _workflowService; + private readonly IPathService _pathService; + + public MainWindowViewModel? MainWindowViewModel { get; set; } + public MainWindowView? MainWindow { get; set; } + + public WindowService( + ITrayService trayService, + INotificationService notificationService, + IWorkflowService workflowService, + IPathService pathService + ) + { + _trayService = trayService; + _notificationService = notificationService; + _workflowService = workflowService; + _pathService = pathService; + } + + public void Deactivate(MainWindowViewModel mainWindowView) + { + var minimized = mainWindowView is { isMinimizedByIdleTimer: false, mainWindow.WindowState: WindowState.Minimized }; + if (mainWindowView.mainWindow != null) + mainWindowView.mainWindow.ShowInTaskbar = !minimized; + mainWindowView.isMinimizedByIdleTimer = minimized; + if (mainWindowView.idleTimer == null) + return; + mainWindowView.idleTimer.IsEnabled = !minimized; + if (minimized) + mainWindowView.idleTimer.Stop(); + else + mainWindowView.idleTimer.Start(); + } + + public void Load(MainWindowViewModel mainWindowView) + { + _trayService.InitializeTray(mainWindowView, this); + + Task.Run(async () => + { + try + { + var appDataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ChemLocalLink" + ); + Directory.CreateDirectory(appDataPath); + var filePath = Path.Combine(appDataPath, "downloads.json"); + + if (File.Exists(filePath) && !string.IsNullOrEmpty(File.ReadAllText(filePath))) + { + var data = File.ReadAllText(filePath); + var downloads = JsonConvert.DeserializeObject>(data) ?? new(); + + // migrate legacy temp directory files if needed + _pathService.MigrateFromLegacyTempDir(downloads.ToList()); + // persist any FilePath changes + var migratedData = JsonConvert.SerializeObject(downloads, Formatting.Indented); + File.WriteAllText(filePath, migratedData); + + if (downloads.Count == 0) + { + mainWindowView.HasFilesDownloaded = false; + } + else + { + foreach (var download in downloads) + { + if (File.Exists(download.FilePath)) + { + if (download.Path == null) + download.Path = string.Empty; + if (download.Token == null && !string.IsNullOrWhiteSpace(mainWindowView.Url)) + download.Token = download.FilePath.ExtractAuthToken(); + + if (download.IsKept && download.IsEdited) + download.IsEdited = false; + + if (string.IsNullOrWhiteSpace(download.Origin) && !string.IsNullOrWhiteSpace(mainWindowView.Url)) + { + try + { + var uri = new Uri(mainWindowView.Url); + download.Origin = uri.Host; + } + catch { } + } + + // ensure new fields exist for legacy entries + if (download.SourceUrl == null && !string.IsNullOrWhiteSpace(mainWindowView.Url)) + download.SourceUrl = mainWindowView.Url; + if (download.FileDownloadTimeStamp == default) + download.FileDownloadTimeStamp = File.GetLastWriteTime(download.FilePath); + + mainWindowView.DownloadedFiles.Insert(0, download); + } + } + + var newData = JsonConvert.SerializeObject(mainWindowView.DownloadedFiles.Reverse(), Formatting.Indented); + File.WriteAllText(filePath, newData); + mainWindowView.HasFilesDownloaded = mainWindowView.DownloadedFiles.Count > 0; + + mainWindowView.RebuildGroups(); + } + } + else + { + mainWindowView.HasFilesDownloaded = false; + } + + if (mainWindowView.args?.Length > 0) + { + var originalUrl = mainWindowView.args.First(); + + try + { + var uri = new Uri(originalUrl); + var queryParams = HttpUtility.ParseQueryString(uri.Query); + string? urlParam = queryParams.Get("url"); + string? pathParam = queryParams.Get("path"); + + if (string.IsNullOrEmpty(urlParam)) + { + mainWindowView.Status = NotificationService.Messages.InvalidUrl; + await _notificationService.ShowNotificationAsync(mainWindowView.Status); + return; + } + + var parsedUrl = HttpUtility.UrlDecode(urlParam); + + if (!string.IsNullOrEmpty(pathParam)) + { + mainWindowView.DeepLinkPath = HttpUtility.UrlDecode(pathParam); + } + + if (parsedUrl == null || parsedUrl == "invalid uri") + { + mainWindowView.Status = NotificationService.Messages.InvalidUrl; + await _notificationService.ShowNotificationAsync(mainWindowView.Status); + return; + } + + var authToken = parsedUrl.ExtractAuthToken(); + if (authToken == null) + { + mainWindowView.Status = NotificationService.Messages.TokenFail; + await _notificationService.ShowNotificationAsync(mainWindowView.Status); + return; + } + + mainWindowView.Url = parsedUrl; + await _workflowService.HandleProcess(mainWindowView, parsedUrl); + } + catch (Exception) + { + mainWindowView.Status = NotificationService.Messages.InvalidUrl; + await _notificationService.ShowNotificationAsync(mainWindowView.Status); + return; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Exception in Load method: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + } + }); + MinimizeWindowOnIdle(); + } + + private void MinimizeWindowOnIdle() + { + try + { + var window = MainWindow!; + + var idleTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(300) }; + idleTimer.Tick += async (sender, e) => + { + var elapsedTime = DateTime.Now - MainWindowViewModel!.lastInteractionTime; + + if (!(elapsedTime.TotalSeconds > 300)) + return; + MainWindowViewModel.isMinimizedByIdleTimer = true; + window.WindowState = WindowState.Minimized; + idleTimer.IsEnabled = false; + idleTimer.Stop(); + await _notificationService.ShowNotificationAsync(NotificationService.Messages.Minimize); + idleTimer.Start(); + window.PointerPressed += (sender, eventArgs) => ResetLastInteractionTime(MainWindowViewModel); + window.PointerMoved += (sender, eventArgs) => ResetLastInteractionTime(MainWindowViewModel); + window.KeyDown += (sender, eventArgs) => ResetLastInteractionTime(MainWindowViewModel); + MainWindowViewModel.lastInteractionTime = DateTime.Now; + }; + } + catch (Exception ex) + { + Console.WriteLine($"Error in MinimizeWindowOnIdle: {ex.Message}"); + throw; + } + } + + private void ResetLastInteractionTime(MainWindowViewModel mainWindowView) + { + mainWindowView.lastInteractionTime = DateTime.Now; + + if (!mainWindowView.isMinimizedByIdleTimer) + { + return; + } + + if (mainWindowView.mainWindow != null) + { + mainWindowView.mainWindow.WindowState = WindowState.Normal; + mainWindowView.mainWindow.ShowInTaskbar = true; + } + mainWindowView.isMinimizedByIdleTimer = false; + + if (mainWindowView.idleTimer != null) + { + mainWindowView.idleTimer.IsEnabled = true; + mainWindowView.idleTimer.Start(); + } + else + { + Console.WriteLine("Error: idleTimer is null."); + } + } + + public void ShowWindow() + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktopApp) + return; + var mainWindow = desktopApp.MainWindow; + if (mainWindow == null) + return; + mainWindow.Show(); + mainWindow.WindowState = WindowState.Normal; + mainWindow.ShowInTaskbar = true; + } +} diff --git a/Services/WorkflowService.cs b/Services/WorkflowService.cs new file mode 100644 index 0000000..0cdff0a --- /dev/null +++ b/Services/WorkflowService.cs @@ -0,0 +1,81 @@ +/// +/// Orchestrates the main workflow for processing chemotion:// URLs and coordinating file operations +/// + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using System.Web; +using ChemLocalLink.Utilities; +using ChemLocalLink.ViewModels; + +namespace ChemLocalLink.Services; + +public interface IWorkflowService +{ + Task HandleProcess(MainWindowViewModel mainWindowView, string url); +} + +internal class WorkflowService : IWorkflowService +{ + private readonly IFileOpsService _fileOpsService; + private readonly INotificationService _notificationService; + + public WorkflowService(IFileOpsService fileOpsService, INotificationService notificationService) + { + _fileOpsService = fileOpsService; + _notificationService = notificationService; + } + + public async Task HandleProcess(MainWindowViewModel mainWindowView, string _url) + { + try + { + if (mainWindowView.IsAlreadyProcessing == false) + { + if (!Uri.TryCreate(mainWindowView.Url, UriKind.Absolute, out _)) + { + mainWindowView.Status = NotificationService.Messages.InvalidUrl; + await _notificationService.ShowNotificationAsync(mainWindowView.Status); + + return; + } + + if (!string.IsNullOrEmpty(_url) && mainWindowView.Url != _url) + mainWindowView.Url = _url; + var token = _url.ExtractAuthToken(); + var downloadedFile = await _fileOpsService.DownloadFile(mainWindowView, token!); + mainWindowView._filePath = downloadedFile?.filePath ?? null; + if (mainWindowView._filePath == null) + { + mainWindowView.Status = NotificationService.Messages.DownloadFail; + await _notificationService.ShowNotificationAsync(mainWindowView.Status); + return; + } + + await _fileOpsService.ProcessFile(mainWindowView._filePath, mainWindowView, downloadedFile?.originalName ?? ""); + + mainWindowView.DeepLinkPath = null; + + mainWindowView.Status = NotificationService.Messages.DownloadSuccessful; + await _notificationService.ShowNotificationAsync(mainWindowView.Status); + } + else + { + mainWindowView.Status = NotificationService.Messages.FileAccessError; + await _notificationService.ShowNotificationAsync(mainWindowView.Status); + } + } + catch (HttpRequestException) + { + mainWindowView.Status = NotificationService.Messages.NetworkError; + await _notificationService.ShowNotificationAsync(mainWindowView.Status); + } + catch (Exception ex) + { + Console.WriteLine($"Error in Process method: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + throw; + } + } +} diff --git a/UserControls/Downloads.axaml b/UserControls/Downloads.axaml deleted file mode 100644 index 452b65a..0000000 --- a/UserControls/Downloads.axaml +++ /dev/null @@ -1,141 +0,0 @@ - - - - - 300 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/UserControls/Downloads.axaml.cs b/UserControls/Downloads.axaml.cs deleted file mode 100644 index 9ae6057..0000000 --- a/UserControls/Downloads.axaml.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Avalonia; -using Avalonia.Controls; -using Avalonia.Markup.Xaml; - -namespace urlhandler.UserControls; - -public partial class Downloads : UserControl { - public Downloads() { - InitializeComponent(); - } -} - diff --git a/AppBuilderExtensions.cs b/Utilities/AppBuilderExtensions.cs similarity index 63% rename from AppBuilderExtensions.cs rename to Utilities/AppBuilderExtensions.cs index 79e4246..3d252f2 100644 --- a/AppBuilderExtensions.cs +++ b/Utilities/AppBuilderExtensions.cs @@ -1,23 +1,32 @@ -using System; +/// +/// Extension methods for configuring Avalonia AppBuilder +/// + +using System; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using DesktopNotifications; using DesktopNotifications.FreeDesktop; using DesktopNotifications.Windows; -namespace urlhandler; +namespace ChemLocalLink.Utilities; -public static class AppBuilderExtensions { - public static AppBuilder SetupDesktopNotifications(this AppBuilder builder, out INotificationManager? manager) { - if (Environment.OSVersion.Platform == PlatformID.Win32NT && Environment.OSVersion.Version.Major >= 10) { +public static class AppBuilderExtensions +{ + public static AppBuilder SetupDesktopNotifications(this AppBuilder builder, out INotificationManager? manager) + { + if (Environment.OSVersion.Platform == PlatformID.Win32NT && Environment.OSVersion.Version.Major >= 10) + { var context = WindowsApplicationContext.FromCurrentProcess(); manager = new WindowsNotificationManager(context); } - else if (Environment.OSVersion.Platform == PlatformID.Unix) { + else if (Environment.OSVersion.Platform == PlatformID.Unix) + { var context = FreeDesktopApplicationContext.FromCurrentProcess(); manager = new FreeDesktopNotificationManager(context); } - else { + else + { // todo: macOS once implemented/stable manager = null; return builder; @@ -27,9 +36,14 @@ public static AppBuilder SetupDesktopNotifications(this AppBuilder builder, out manager.Initialize().GetAwaiter().GetResult(); var manager_ = manager; - builder.AfterSetup(b => { - if (b.Instance?.ApplicationLifetime is IControlledApplicationLifetime lifetime) { - lifetime.Exit += (s, e) => { manager_.Dispose(); }; + builder.AfterSetup(b => + { + if (b.Instance?.ApplicationLifetime is IControlledApplicationLifetime lifetime) + { + lifetime.Exit += (s, e) => + { + manager_.Dispose(); + }; } }); diff --git a/Utilities/DateTimeToStringConverter.cs b/Utilities/DateTimeToStringConverter.cs new file mode 100644 index 0000000..b30cfb8 --- /dev/null +++ b/Utilities/DateTimeToStringConverter.cs @@ -0,0 +1,83 @@ +/// +/// Converts DateTime and Unix timestamps to formatted strings +/// + +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ChemLocalLink.Utilities; + +public class DateTimeToStringConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is DateTime dateTime) + { + return dateTime.ToString("dd/MM/yyyy h:mm tt", culture); + } + else if (value is long epoch) + { + DateTime dateTimeFromEpoch = DateTimeOffset.FromUnixTimeSeconds(epoch).DateTime; + DateTime localDateTime = dateTimeFromEpoch.ToLocalTime(); + DateTime now = DateTime.Now; + + if (targetType == typeof(IBrush)) + { + if (now > localDateTime) + return new SolidColorBrush(Color.FromRgb(231, 76, 60)); + + TimeSpan timeRemaining = localDateTime - now; + if (timeRemaining.TotalHours < 24) + return new SolidColorBrush(Color.FromRgb(255, 193, 7)); + + return new SolidColorBrush(Color.FromRgb(39, 174, 96)); + } + + string? paramStr = parameter?.ToString(); + + if (paramStr == "short") + { + if (now > localDateTime) + return "Expired"; + + TimeSpan timeRemaining = localDateTime - now; + if (timeRemaining.TotalDays >= 1) + return $"{(int)timeRemaining.TotalDays}d left"; + else if (timeRemaining.TotalHours >= 1) + return $"{(int)timeRemaining.TotalHours}h left"; + else + return $"{(int)timeRemaining.TotalMinutes}m left"; + } + + if (paramStr == "tooltip") + { + if (now > localDateTime) + return $"File upload expired on {localDateTime.ToString("dd/MM/yyyy h:mm tt", culture)}"; + + TimeSpan timeRemaining = localDateTime - now; + string timeRemainingText; + + if (timeRemaining.TotalDays >= 1) + timeRemainingText = $"{(int)timeRemaining.TotalDays} day(s) and {timeRemaining.Hours} hour(s)"; + else if (timeRemaining.TotalHours >= 1) + timeRemainingText = $"{(int)timeRemaining.TotalHours} hour(s) and {timeRemaining.Minutes} minute(s)"; + else + timeRemainingText = $"{(int)timeRemaining.TotalMinutes} minute(s)"; + + return $"File upload expires in {timeRemainingText}\nExpiration date: {localDateTime.ToString("dd/MM/yyyy h:mm tt", culture)}"; + } + + return now < localDateTime + ? "File upload will expire on " + localDateTime.ToString("dd/MM/yyyy h:mm tt", culture) + : "File upload expired."; + } + return value!; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Utilities/FileExtensionToIconConverter.cs b/Utilities/FileExtensionToIconConverter.cs new file mode 100644 index 0000000..8250722 --- /dev/null +++ b/Utilities/FileExtensionToIconConverter.cs @@ -0,0 +1,90 @@ +/// +/// Converts file extensions to a FontAwesome icon and color. +/// + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ChemLocalLink.Utilities; + +public class FileExtensionToIconConverter : IValueConverter +{ + private const string DefaultIcon = "fa-regular fa-file"; + private static readonly SolidColorBrush DefaultBrush = new(Color.Parse("#6C757D")); + + private static readonly Dictionary ExtensionToIcon = BuildMap(); + + private static Dictionary BuildMap() + { + var m = new Dictionary(StringComparer.OrdinalIgnoreCase); + + void Add(string color, string icon, params string[] exts) + { + foreach (var e in exts) + m[e.StartsWith('.') ? e : "." + e] = (icon, color); + } + + const string Red = "#E74C3C", + Orange = "#F39C12", + Yellow = "#F1C40F", + Green = "#27AE60", + Teal = "#1ABC9C", + Blue = "#2980B9", + Indigo = "#3F51B5", + Purple = "#8E44AD", + Gray = "#6C757D"; + + Add(Red, "fa-solid fa-file-pdf", ".pdf"); + Add(Blue, "fa-solid fa-file-word", ".doc", ".docx", ".odt", ".pages"); + Add(Green, "fa-solid fa-file-excel", ".xls", ".xlsx", ".ods", ".csv", ".tsv"); + Add(Orange, "fa-solid fa-file-powerpoint", ".ppt", ".pptx", ".odp", ".key"); + Add(Gray, "fa-solid fa-file-lines", ".txt", ".rtf", ".log", ".out", ".readme"); + + Add(Yellow, "fa-brands fa-js", ".js"); + Add(Blue, "fa-solid fa-file-code", ".ts", ".xaml", ".axaml"); + Add(Indigo, "fa-brands fa-python", ".py"); + Add(Purple, "fa-solid fa-file-code", ".cs", ".cpp", ".c", ".h", ".java", ".go", ".rb", ".swift", ".kt"); + Add(Orange, "fa-solid fa-brackets-curly", ".json", ".xml"); + Add(Gray, "fa-solid fa-file-lines", ".yml", ".yaml", ".ini", ".cfg", ".conf"); + + Add(Purple, "fa-solid fa-file-image", ".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".svg", ".webp", ".ico"); + Add(Orange, "fa-solid fa-file-audio", ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a"); + Add(Red, "fa-solid fa-file-video", ".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm"); + + Add(Orange, "fa-solid fa-file-zipper", ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz"); + + Add(Gray, "fa-solid fa-gear", ".exe", ".dll", ".so", ".dylib"); + Add(Blue, "fa-solid fa-box", ".msi", ".appx", ".vhd"); + Add(Red, "fa-solid fa-box", ".rpm"); + Add(Teal, "fa-solid fa-compact-disc", ".iso", ".img"); + + Add(Blue, "fa-solid fa-database", ".db", ".sqlite", ".sqlite3", ".sql"); + + Add(Green, "fa-solid fa-flask-vial", ".jdx"); + Add(Teal, "fa-solid fa-chart-line", ".dx"); + Add(Purple, "fa-solid fa-atom", ".mnova"); + Add(Green, "fa-solid fa-flask", ".mol", ".sdf", ".cml", ".cdx", ".cdxml", ".xyz"); + + return m; + } + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var isColorRequest = string.Equals(parameter?.ToString(), "color", StringComparison.OrdinalIgnoreCase); + var ext = value is string s ? Path.GetExtension(s) : ""; + + if (!string.IsNullOrEmpty(ext) && ExtensionToIcon.TryGetValue(ext, out var iconData)) + { + return isColorRequest ? new SolidColorBrush(Color.Parse(iconData.Color)) : iconData.Icon; + } + + return isColorRequest ? DefaultBrush : DefaultIcon; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/Utilities/FileSelectionConverter.cs b/Utilities/FileSelectionConverter.cs new file mode 100644 index 0000000..df4c697 --- /dev/null +++ b/Utilities/FileSelectionConverter.cs @@ -0,0 +1,28 @@ +/// +/// Converter to determine if a file is currently selected and return appropriate background +/// + +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using ChemLocalLink.Models; + +namespace ChemLocalLink.Utilities; + +public class FileSelectionConverter : IMultiValueConverter +{ + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count != 2 || values[0] is not DownloadModel currentFile) + return Brushes.Transparent; + + if (values[1] is not DownloadModel selectedFile) + return Brushes.Transparent; + + return currentFile.FileId == selectedFile.FileId + ? new SolidColorBrush(Color.FromArgb(80, 0, 120, 215)) + : Brushes.Transparent; + } +} diff --git a/Extensions/HttpClientExtension.cs b/Utilities/HttpClientExtension.cs similarity index 52% rename from Extensions/HttpClientExtension.cs rename to Utilities/HttpClientExtension.cs index 8d4cc96..273a55a 100644 --- a/Extensions/HttpClientExtension.cs +++ b/Utilities/HttpClientExtension.cs @@ -1,19 +1,35 @@ +/// +/// Extends HttpClient with upload and download progress tracking +/// + using System; using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using urlhandler.Models; +using ChemLocalLink.Models; -namespace urlhandler.Extensions; +namespace ChemLocalLink.Utilities; -public static class HttpClientExtension { +public static class HttpClientExtension +{ private const int BufferSize = 8192; - public static async Task<(HttpResponseMessage Response, byte[] Content)> GetWithProgressAsync(this HttpClient client, string requestUri, IProgress progress, CancellationToken cancellationToken = default) { - using var responseMessage = await client.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - - if (!responseMessage.IsSuccessStatusCode) { + public static async Task<(HttpResponseMessage Response, byte[] Content)> GetWithProgressAsync( + this HttpClient client, + string requestUri, + IProgress progress, + CancellationToken cancellationToken = default + ) + { + using var responseMessage = await client.GetAsync( + requestUri, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ); + + if (!responseMessage.IsSuccessStatusCode) + { return (responseMessage, []); } @@ -21,16 +37,36 @@ public static class HttpClientExtension { return (responseMessage, content); } - public static async Task PostWithProgressAsync(this HttpClient client, string requestUri, HttpContent content, IProgress progress, CancellationToken cancellationToken = default, bool isUpload = false) { + public static async Task PostWithProgressAsync( + this HttpClient client, + string requestUri, + HttpContent content, + IProgress progress, + CancellationToken cancellationToken = default, + bool isUpload = false + ) + { using var requestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri); requestMessage.Content = content; - var responseMessage = await client.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - if (isUpload) await UploadWithProgressAsync(content, progress, cancellationToken); - else await ProcessResponseAsync(responseMessage, progress, cancellationToken); + var responseMessage = await client.SendAsync( + requestMessage, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ); + if (isUpload) + await UploadWithProgressAsync(content, progress, cancellationToken); + else + await ProcessResponseAsync(responseMessage, progress, cancellationToken); return responseMessage; } - private static async Task UploadWithProgressAsync(HttpContent content, IProgress progress, CancellationToken cancellationToken) { + + private static async Task UploadWithProgressAsync( + HttpContent content, + IProgress progress, + CancellationToken cancellationToken + ) + { await using var contentStream = await content.ReadAsStreamAsync(cancellationToken); var totalBytesExpected = content.Headers.ContentLength ?? -1; @@ -38,25 +74,32 @@ private static async Task UploadWithProgressAsync(HttpContent content, IProgress var totalReportedRead = 0L; var buffer = new byte[BufferSize]; - while (true) { + while (true) + { var bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); if (bytesRead == 0) break; totalBytesRead += bytesRead; - if (totalBytesRead - totalReportedRead >= BufferSize) { + if (totalBytesRead - totalReportedRead >= BufferSize) + { var percentage = totalBytesExpected > 0 ? (double)totalBytesRead / totalBytesExpected * 100 : -1; - progress.Report(new ProgressInfo(totalBytesRead, totalBytesExpected, percentage)); + progress.Report(new ProgressModel(totalBytesRead, totalBytesExpected, percentage)); totalReportedRead = totalBytesRead; } } var finalPercentage = totalBytesExpected > 0 ? (double)totalBytesRead / totalBytesExpected * 100 : -1; - progress.Report(new ProgressInfo(totalBytesRead, totalBytesExpected, finalPercentage)); + progress.Report(new ProgressModel(totalBytesRead, totalBytesExpected, finalPercentage)); } - private static async Task ProcessResponseAsync(HttpResponseMessage responseMessage, IProgress progress, CancellationToken cancellationToken) { + private static async Task ProcessResponseAsync( + HttpResponseMessage responseMessage, + IProgress progress, + CancellationToken cancellationToken + ) + { await using var contentStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken); var totalBytesExpected = responseMessage.Content.Headers.ContentLength ?? -1; @@ -65,7 +108,8 @@ private static async Task ProcessResponseAsync(HttpResponseMessage respo var buffer = new byte[BufferSize]; var contentBytes = new MemoryStream(); - while (true) { + while (true) + { var bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); if (bytesRead == 0) break; @@ -73,15 +117,16 @@ private static async Task ProcessResponseAsync(HttpResponseMessage respo totalBytesRead += bytesRead; await contentBytes.WriteAsync(buffer, 0, bytesRead, cancellationToken); - if (totalBytesRead - totalReportedRead >= BufferSize) { + if (totalBytesRead - totalReportedRead >= BufferSize) + { var percentage = totalBytesExpected > 0 ? (double)totalBytesRead / totalBytesExpected * 100 : -1; - progress.Report(new ProgressInfo(totalBytesRead, totalBytesExpected, percentage)); + progress.Report(new ProgressModel(totalBytesRead, totalBytesExpected, percentage)); totalReportedRead = totalBytesRead; } } var finalPercentage = totalBytesExpected > 0 ? (double)totalBytesRead / totalBytesExpected * 100 : -1; - progress.Report(new ProgressInfo(totalBytesRead, totalBytesExpected, finalPercentage)); + progress.Report(new ProgressModel(totalBytesRead, totalBytesExpected, finalPercentage)); return contentBytes.ToArray(); } diff --git a/Utilities/IndexToBooleanConverter.cs b/Utilities/IndexToBooleanConverter.cs new file mode 100644 index 0000000..b510498 --- /dev/null +++ b/Utilities/IndexToBooleanConverter.cs @@ -0,0 +1,25 @@ +/// +/// Converts integer index to boolean for UI bindings +/// + +using System; +using Avalonia.Data.Converters; + +namespace ChemLocalLink.Utilities; + +public class IndexToBooleanConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + if (value is int index) + { + return index >= 0; + } + return false; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/Utilities/ServiceCollectionExtensions.cs b/Utilities/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d819a3f --- /dev/null +++ b/Utilities/ServiceCollectionExtensions.cs @@ -0,0 +1,50 @@ +/// +/// Configures dependency injection for ChemLocalLink +/// + +using System; +using System.Net.Http; +using ChemLocalLink.Services; +using ChemLocalLink.ViewModels; +using DesktopNotifications; +using Microsoft.Extensions.DependencyInjection; + +namespace ChemLocalLink.Utilities; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices( + this IServiceCollection services, + INotificationManager? notificationManager = null + ) + { + // http client with configuration + services.AddSingleton(provider => + { + var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromMinutes(5); // 5 minute timeout for downloads + httpClient.DefaultRequestHeaders.Add("User-Agent", "ChemLocalLink/1.0"); + return httpClient; + }); + + // Services + if (notificationManager != null) + { + services.AddSingleton(notificationManager); + } + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // ViewModels + services.AddTransient(); + + return services; + } +} diff --git a/Behaviors/DisabledToolTipShow.cs b/Utilities/ShowTooltipExtension.cs similarity index 58% rename from Behaviors/DisabledToolTipShow.cs rename to Utilities/ShowTooltipExtension.cs index 81b8cd9..b6570aa 100644 --- a/Behaviors/DisabledToolTipShow.cs +++ b/Utilities/ShowTooltipExtension.cs @@ -1,15 +1,21 @@ +/// +/// Enables tooltips on disabled Avalonia controls +/// + using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.VisualTree; -namespace urlhandler.Behaviors; +namespace ChemLocalLink.Utilities; //source: https://github.com/AvaloniaUI/Avalonia/issues/3847#issuecomment-1618790059 -public static class ShowDisabledTooltipExtension { +public static class ShowTooltipExtension +{ #region Constructors - static ShowDisabledTooltipExtension() { + static ShowTooltipExtension() + { ShowOnDisabledProperty.Changed.AddClassHandler((x, y) => HandleShowOnDisabledChanged(x, y)); } @@ -18,36 +24,44 @@ static ShowDisabledTooltipExtension() { #region Properties #region ShowOnDisabled AvaloniaProperty - public static bool GetShowOnDisabled(AvaloniaObject obj) { + public static bool GetShowOnDisabled(AvaloniaObject obj) + { return obj.GetValue(ShowOnDisabledProperty); } - public static void SetShowOnDisabled(AvaloniaObject obj, bool value) { + public static void SetShowOnDisabled(AvaloniaObject obj, bool value) + { obj.SetValue(ShowOnDisabledProperty, value); } - public static readonly AttachedProperty ShowOnDisabledProperty = - AvaloniaProperty.RegisterAttached( - "ShowOnDisabled", - false, false); + public static readonly AttachedProperty ShowOnDisabledProperty = AvaloniaProperty.RegisterAttached< + object, + Control, + bool + >("ShowOnDisabled", false, false); - private static void HandleShowOnDisabledChanged(Control control, AvaloniaPropertyChangedEventArgs e) { - if (e.NewValue is bool isEnabledVal && isEnabledVal) { + private static void HandleShowOnDisabledChanged(Control control, AvaloniaPropertyChangedEventArgs e) + { + if (e.NewValue is bool isEnabledVal && isEnabledVal) + { control.DetachedFromVisualTree += AttachedControl_DetachedFromVisualOrExtension!; control.AttachedToVisualTree += AttachedControl_AttachedToVisualTree!; - if (control.IsInitialized) { + if (control.IsInitialized) + { // enabled after visual attached AttachedControl_AttachedToVisualTree(control, null); } } - else { + else + { AttachedControl_DetachedFromVisualOrExtension(control, null); } - } - private static void AttachedControl_AttachedToVisualTree(object sender, VisualTreeAttachmentEventArgs? e) { - if (sender is not Control control) { + private static void AttachedControl_AttachedToVisualTree(object sender, VisualTreeAttachmentEventArgs? e) + { + if (sender is not Control control) + { return; } var tl = TopLevel.GetTopLevel(control); @@ -55,44 +69,43 @@ private static void AttachedControl_AttachedToVisualTree(object sender, VisualTr tl!.AddHandler(TopLevel.PointerMovedEvent, TopLevel_PointerMoved!, RoutingStrategies.Tunnel); } - - private static void AttachedControl_DetachedFromVisualOrExtension(object s, VisualTreeAttachmentEventArgs? e) { - if (s is not Control control) { + private static void AttachedControl_DetachedFromVisualOrExtension(object s, VisualTreeAttachmentEventArgs? e) + { + if (s is not Control control) + { return; } control.DetachedFromVisualTree -= AttachedControl_DetachedFromVisualOrExtension!; control.AttachedToVisualTree -= AttachedControl_AttachedToVisualTree!; - if (TopLevel.GetTopLevel(control) is not TopLevel tl) { + if (TopLevel.GetTopLevel(control) is not TopLevel tl) + { return; } tl.RemoveHandler(TopLevel.PointerMovedEvent, TopLevel_PointerMoved!); } - private static void TopLevel_PointerMoved(object sender, global::Avalonia.Input.PointerEventArgs e) { - if (sender is not Control tl) { + private static void TopLevel_PointerMoved(object sender, global::Avalonia.Input.PointerEventArgs e) + { + if (sender is not Control tl) + { return; } - var attached_controls = - tl.GetVisualDescendants().Where(x => GetShowOnDisabled(x)).Cast(); + var attached_controls = tl.GetVisualDescendants().Where(x => GetShowOnDisabled(x)).Cast(); // find disabled children under pointer w/ this extension enabled - var disabled_child_under_pointer = - attached_controls - .FirstOrDefault(x => - x.Bounds.Contains(e.GetPosition(x.Parent as Visual)) && - x.IsEffectivelyVisible && - !x.IsEnabled); - if (disabled_child_under_pointer != null) { + var disabled_child_under_pointer = attached_controls.FirstOrDefault(x => + x.Bounds.Contains(e.GetPosition(x.Parent as Visual)) && x.IsEffectivelyVisible && !x.IsEnabled + ); + if (disabled_child_under_pointer != null) + { // manually show tooltip ToolTip.SetIsOpen(disabled_child_under_pointer, true); } - var disabled_tooltips_to_hide = - attached_controls - .Where(x => - ToolTip.GetIsOpen(x) && - x != disabled_child_under_pointer && - !x.IsEnabled); - foreach (var dcst in disabled_tooltips_to_hide) { + var disabled_tooltips_to_hide = attached_controls.Where(x => + ToolTip.GetIsOpen(x) && x != disabled_child_under_pointer && !x.IsEnabled + ); + foreach (var dcst in disabled_tooltips_to_hide) + { ToolTip.SetIsOpen(dcst, false); } } @@ -100,4 +113,3 @@ private static void TopLevel_PointerMoved(object sender, global::Avalonia.Input. #endregion } - diff --git a/Utilities/StringExtension.cs b/Utilities/StringExtension.cs new file mode 100644 index 0000000..84b0043 --- /dev/null +++ b/Utilities/StringExtension.cs @@ -0,0 +1,109 @@ +/// +/// Adds string utilities for file size formatting, URL parsing, and checksum +/// + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Web; + +namespace ChemLocalLink.Utilities; + +public static class StringExtension +{ + private const int scale = 1024; + private static readonly string[] orders = { "B", "kB", "MB", "GB", "TB" }; + + public static string FormatBytes(this long bytes) + { + int orderIndex = 0; + decimal adjustedBytes = bytes; + + while (adjustedBytes >= scale && orderIndex < orders.Length - 1) + { + adjustedBytes /= scale; + orderIndex++; + } + + // format the bytes with the appropriate unit + return $"{adjustedBytes:##.##} {orders[orderIndex]}"; + } + + public static string FileCheckSum(this string filePath) + { + using var stream = File.OpenRead(filePath); + using var sha256 = SHA256.Create(); + var checksum = BitConverter.ToString(sha256.ComputeHash(stream)).Replace("-", "").ToLower(); + return checksum; + } + + public static string? ParseUrl(this string inputUrl) + { + try + { + var uri = new Uri(inputUrl); + var parse = HttpUtility.ParseQueryString(uri.Query).Get("url"); + + return HttpUtility.UrlDecode(parse); + } + catch (Exception ex) + { + Console.WriteLine($"Error parsing URL: {ex.Message}"); + return "invalid uri"; + } + } + + public static string? ExtractAuthToken(this string decodedUrl) + { + var lastSlashIndex = decodedUrl.LastIndexOf('/'); + if (lastSlashIndex < 0 || lastSlashIndex == decodedUrl.Length - 1) + { + return null; + } + return decodedUrl[(lastSlashIndex + 1)..]; + } + + public static string? ExtractOriginHost(this string? url) + { + if (string.IsNullOrWhiteSpace(url)) + return null; + + try + { + if (url.StartsWith("chemotion://", StringComparison.OrdinalIgnoreCase)) + { + var parsed = url.ParseUrl(); + if (!string.IsNullOrWhiteSpace(parsed)) + { + url = parsed; + } + } + + var uri = new Uri(url); + return uri.Host; + } + catch + { + return null; + } + } + + // normalize path separators and resolve to canonical form + public static string NormalizePath(this string path) + { + if (string.IsNullOrWhiteSpace(path)) + return path; + var sep = Path.DirectorySeparatorChar; + var otherSep = sep == '/' ? '\\' : '/'; + var replaced = path.Replace(otherSep, sep); + try + { + // only call GetFullPath if input is rooted + return Path.IsPathRooted(replaced) ? Path.GetFullPath(replaced) : replaced; + } + catch + { + return replaced; + } + } +} diff --git a/Utilities/WindowMenuBehavior.cs b/Utilities/WindowMenuBehavior.cs new file mode 100644 index 0000000..b771f64 --- /dev/null +++ b/Utilities/WindowMenuBehavior.cs @@ -0,0 +1,106 @@ +/// +/// Enables context menu command routing in Avalonia +/// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Xaml.Interactivity; +using ChemLocalLink.ViewModels; +using CommunityToolkit.Mvvm.Input; + +namespace ChemLocalLink.Utilities; + +public class WindowMenuBehavior : Behavior +{ + public static readonly StyledProperty ViewModelProperty = AvaloniaProperty.Register< + WindowMenuBehavior, + object + >(nameof(ViewModel)); + + public object ViewModel + { + get => GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + public static readonly StyledProperty CommandsProperty = AvaloniaProperty.Register< + WindowMenuBehavior, + object + >(nameof(IRelayCommand)); + + public object Commands + { + get => GetValue(CommandsProperty); + set => SetValue(CommandsProperty, value); + } + + public static readonly StyledProperty FileModelProperty = AvaloniaProperty.Register< + WindowMenuBehavior, + object + >(nameof(FileModel)); + + public object FileModel + { + get => GetValue(FileModelProperty); + set => SetValue(FileModelProperty, value); + } + + protected override void OnAttached() + { + base.OnAttached(); + if (AssociatedObject != null) + { + AssociatedObject.Click += OnMenuItemClick!; + } + } + + protected override void OnDetaching() + { + base.OnDetaching(); + if (AssociatedObject != null) + { + AssociatedObject.Click -= OnMenuItemClick!; + } + } + + private async void OnMenuItemClick(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (ViewModel != null) + { + if (Commands != null) + { + var viewModel = ViewModel as MainWindowViewModel; + + var fileModel = FileModel as ChemLocalLink.Models.DownloadModel; + + if (fileModel != null && viewModel != null) + { + viewModel.SelectFile(fileModel); + } + + switch (Commands as string) + { + case "uploadndelete": + await viewModel!.UploadFiles("delete"); + break; + case "uploadnkeep": + await viewModel!.UploadFiles(""); + break; + case "deleteFile": + viewModel?.DeleteSelectedFile(); + break; + case "duplicateFile": + await viewModel!.DuplicateAndRenameFile(); + break; + case "openFile": + viewModel?.OpenFile(); + break; + case "openDir": + viewModel?.OpenDownloadDirectory(); + break; + default: + break; + } + } + } + } +} diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index 151152c..63c24d0 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -1,7 +1,13 @@ -using System; +/// +/// Main view model managing app state, file operations, and UI logic +/// + +using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; +using System.Linq; using System.Net.Http; using System.Reflection; using System.Runtime.InteropServices; @@ -10,173 +16,601 @@ using Avalonia.Input; using Avalonia.Styling; using Avalonia.Threading; +using ChemLocalLink.Models; +using ChemLocalLink.Services; +using ChemLocalLink.Utilities; +using ChemLocalLink.Views; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Newtonsoft.Json; -using urlhandler.Extensions; -using urlhandler.Helpers; -using urlhandler.Models; -using urlhandler.Services; -using urlhandler.Views; -using INotificationManager = DesktopNotifications.INotificationManager; using Timer = System.Timers; -namespace urlhandler.ViewModels; +namespace ChemLocalLink.ViewModels; -public partial class - MainWindowViewModel : ObservableObject { - internal readonly HttpClient _httpClient = new HttpClient(); +public partial class MainWindowViewModel : ObservableObject, IDisposable +{ + internal readonly HttpClient _httpClient; internal string? _filePath; - internal INotificationManager? notificationManager; + internal readonly INotificationService _notificationService; internal readonly DispatcherTimer? idleTimer = new DispatcherTimer(); + internal Timer.Timer? fileMonitorTimer; internal DateTime lastInteractionTime; internal bool isMinimizedByIdleTimer = false; - internal readonly MainWindow mainWindow; - internal readonly string[] args; - internal readonly DownloadService _downloadService; - internal readonly UploadService _uploadService; - internal readonly FileService _fileService; + internal MainWindowView? mainWindow; + internal string[]? args; + internal readonly IFileOpsService _fileOpsService; + internal readonly IApiService _apiService; + internal readonly ITrayService _trayService; + internal readonly IWindowService _windowService; + internal readonly IWorkflowService _workflowService; + internal readonly IJsonDataService _jsonDataService; + internal readonly IPathService _pathService; + internal readonly ISessionService _sessionService; internal Process? _fileProcess; - [ObservableProperty] private string _appVersion = $"{Assembly.GetExecutingAssembly().GetName().Version!.Major}.{Assembly.GetExecutingAssembly().GetName().Version!.Minor}.{Assembly.GetExecutingAssembly().GetName().Version!.Build}"; - [ObservableProperty][NotifyPropertyChangedFor(nameof(HasFilesDownloaded))] ObservableCollection _downloadedFiles = []; - [ObservableProperty] private ObservableCollection _editedFileIds = []; - [ObservableProperty] int _selectedDownloadedFileIndex = -1; - [ObservableProperty] bool _hasFilesDownloaded; - [ObservableProperty] private double _fileUpDownProgress; - [ObservableProperty] string? _fileUpDownProgressText = ""; - [ObservableProperty] private string? _url; - [ObservableProperty] string? _status = ""; - [ObservableProperty] object? _selectedUrl; - [ObservableProperty] private bool _isAlreadyProcessing; - [ObservableProperty] private bool _isManualEnabled; - [ObservableProperty] string _authToken = ""; - [ObservableProperty][NotifyPropertyChangedFor(nameof(ThemeToolTip))] private bool _isDarkMode = Application.Current!.ActualThemeVariant == ThemeVariant.Dark; - [ObservableProperty] private string _themeButtonIcon = Application.Current!.ActualThemeVariant == ThemeVariant.Dark ? "fa-solid fa-lightbulb" : "fa-regular fa-lightbulb"; - [ObservableProperty] private string _themeToolTip = Application.Current!.ActualThemeVariant == ThemeVariant.Light ? "Switch to Dark Mode" : "Switch to Light Mode"; - - partial void OnIsDarkModeChanged(bool value) { + [ObservableProperty] + private string _appVersion = + $"{Assembly.GetExecutingAssembly().GetName().Version!.Major}.{Assembly.GetExecutingAssembly().GetName().Version!.Minor}.{Assembly.GetExecutingAssembly().GetName().Version!.Build}"; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasFilesDownloaded))] + ObservableCollection _downloadedFiles = []; + + [ObservableProperty] + private ObservableCollection _downloadedByOrigin = []; + + [ObservableProperty] + private DownloadModel? _selectedDownloadedFile; + + partial void OnSelectedDownloadedFileChanged(DownloadModel? value) + { + if (value == null) + { + SelectedDownloadedFileIndex = -1; + return; + } + + var idx = DownloadedFiles.IndexOf(value); + SelectedDownloadedFileIndex = idx; + } + + [ObservableProperty] + private ObservableCollection _editedFileIds = []; + + [ObservableProperty] + int _selectedDownloadedFileIndex = -1; + + partial void OnSelectedDownloadedFileIndexChanged(int value) + { + if (value >= 0 && value < DownloadedFiles.Count) + { + if (!ReferenceEquals(SelectedDownloadedFile, DownloadedFiles[value])) + { + SelectedDownloadedFile = DownloadedFiles[value]; + } + } + } + + [ObservableProperty] + bool _hasFilesDownloaded; + + [ObservableProperty] + private double _fileUpDownProgress; + + [ObservableProperty] + string? _fileUpDownProgressText = ""; + + [ObservableProperty] + private string? _url; + + [ObservableProperty] + string? _status = ""; + + [ObservableProperty] + object? _selectedUrl; + + [ObservableProperty] + private bool _isAlreadyProcessing; + + [ObservableProperty] + string _authToken = ""; + + [ObservableProperty] + string? _deepLinkPath; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ThemeToolTip))] + private bool _isDarkMode = Application.Current!.ActualThemeVariant == ThemeVariant.Dark; + + [ObservableProperty] + private string _themeButtonIcon = + Application.Current!.ActualThemeVariant == ThemeVariant.Dark ? "fa-solid fa-lightbulb" : "fa-regular fa-lightbulb"; + + [ObservableProperty] + private string _themeToolTip = + Application.Current!.ActualThemeVariant == ThemeVariant.Light ? "Switch to Dark Mode" : "Switch to Light Mode"; + + partial void OnIsDarkModeChanged(bool value) + { ThemeButtonIcon = value ? "fa-solid fa-lightbulb" : "fa-regular fa-lightbulb"; ThemeToolTip = value ? "Switch to Light Mode" : "Switch to Dark Mode"; Application.Current!.RequestedThemeVariant = value ? ThemeVariant.Dark : ThemeVariant.Light; - Theme.SaveCurrentTheme(value); + _jsonDataService.SaveCurrentTheme(value); + } + + public MainWindowViewModel( + HttpClient httpClient, + IFileOpsService fileOpsService, + IApiService apiService, + ITrayService trayService, + IWindowService windowService, + INotificationService notificationService, + IWorkflowService workflowService, + IJsonDataService jsonDataService, + IPathService pathService, + ISessionService sessionService + ) + { + _httpClient = httpClient; + _fileOpsService = fileOpsService; + _apiService = apiService; + _trayService = trayService; + _windowService = windowService; + _notificationService = notificationService; + _workflowService = workflowService; + _jsonDataService = jsonDataService; + _pathService = pathService; + _sessionService = sessionService; + + IsDarkMode = _jsonDataService.LoadCurrentTheme(); + Process = new RelayCommand(_ => Task.Run(async () => await ProcessCommand())); } - public MainWindowViewModel(MainWindow mainWindow, string[] args) { + public void Initialize(MainWindowView mainWindow, string[] args) + { this.mainWindow = mainWindow; - IsDarkMode = Theme.LoadCurrentTheme(); this.args = args ?? throw new ArgumentNullException(nameof(args)); - _downloadService = new DownloadService(); - _uploadService = new UploadService(); - _fileService = new FileService(); - Process = new RelayCommand(_ => Task.Run(async () => await ProcessCommand())); + // Set references in the WindowService + _windowService.MainWindow = mainWindow; + _windowService.MainWindowViewModel = this; + SetupEventHandlers(); + + DownloadedFiles.CollectionChanged += (s, e) => RebuildGroups(); } - private void SetupEventHandlers() { - mainWindow.Loaded += MainWindow_Loaded; - mainWindow.Deactivated += (s, e) => WindowHelper.Deactivate(this); + private void SetupEventHandlers() + { + if (mainWindow != null) + { + mainWindow.Loaded += MainWindow_Loaded; + mainWindow.Deactivated += (s, e) => _windowService.Deactivate(this); + } } - private void MainWindow_Loaded(object? sender, EventArgs e) { - WindowHelper.Load(this); - var timer = new Timer.Timer(1); - timer.Elapsed += OnTimedEvent; - timer.Enabled = true; + private void MainWindow_Loaded(object? sender, EventArgs e) + { + _windowService.Load(this); + fileMonitorTimer = new Timer.Timer(5000); + fileMonitorTimer.Elapsed += OnTimedEvent; + fileMonitorTimer.Enabled = true; } - private void OnTimedEvent(object? source, Timer.ElapsedEventArgs e) { - try { - var downloadedFiles = WindowHelper.MainWindowViewModel?.DownloadedFiles; - if (downloadedFiles == null) return; + private void OnTimedEvent(object? source, Timer.ElapsedEventArgs e) + { + try + { + var downloadedFiles = _windowService.MainWindowViewModel?.DownloadedFiles; + if (downloadedFiles == null) + return; + + foreach (var file in downloadedFiles) + { + // check if file still exists before calculating checksum + if (!File.Exists(file.FilePath)) + { + file.IsEdited = false; + continue; + } - foreach (var file in downloadedFiles) { var fileSumOnDisk = file.FilePath.FileCheckSum(); var fileSumOnDownload = file.FileSumOnDownload; - if (!fileSumOnDisk.Equals(fileSumOnDownload)) { - if (WindowHelper.MainWindowViewModel != null && !WindowHelper.MainWindowViewModel.EditedFileIds.Contains(file.FileId)) { - WindowHelper.MainWindowViewModel.EditedFileIds.Add(file.FileId); + if (!fileSumOnDisk.Equals(fileSumOnDownload)) + { + if ( + _windowService.MainWindowViewModel != null + && !_windowService.MainWindowViewModel.EditedFileIds.Contains(file.FileId) + ) + { + _windowService.MainWindowViewModel.EditedFileIds.Add(file.FileId); } var downloadedFile = DownloadedFiles[DownloadedFiles.IndexOf(file)]; - downloadedFile.IsEdited = !downloadedFile.IsKept; downloadedFile.FileSize = new FileInfo(downloadedFile.FilePath).Length.FormatBytes(); + downloadedFile.IsEdited = !downloadedFile.IsKept; + downloadedFile.FileSize = new FileInfo(downloadedFile.FilePath).Length.FormatBytes(); } - else { + else + { file.IsEdited = false; } } } - catch (Exception ex) { + catch (Exception ex) + { Debug.WriteLine(ex.Message); } } - [RelayCommand] public void OnDownloadDoubleTapped(TappedEventArgs e) => OpenFile(); + [RelayCommand] + public void OnDownloadDoubleTapped(TappedEventArgs e) => OpenFile(); [RelayCommand] - public void OpenDownloadDirectory() { - var folderPath = Path.Combine(Path.GetTempPath(), "chemotion"); + public void OpenDownloadDirectory() + { + var folderPath = + SelectedDownloadedFile != null + ? Path.GetDirectoryName(SelectedDownloadedFile.FilePath) ?? _pathService.GetDownloadDirectory() + : _pathService.GetDownloadDirectory(); + + Debug.WriteLine($"Opening folder: {folderPath}"); - if (Directory.Exists(folderPath)) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + if (Directory.Exists(folderPath)) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { using var process = new Process(); - process.StartInfo = new ProcessStartInfo("explorer.exe", folderPath) { - UseShellExecute = true - }; + process.StartInfo = new ProcessStartInfo("explorer.exe", folderPath) { UseShellExecute = true, }; process.Start(); } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { using var process = new Process(); - process.StartInfo = new ProcessStartInfo("xdg-open", folderPath) { - UseShellExecute = true - }; + process.StartInfo = new ProcessStartInfo("xdg-open", folderPath) { UseShellExecute = true, }; + process.Start(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo("open", folderPath) { UseShellExecute = true, }; process.Start(); } } } [RelayCommand] - public void OpenFile() { - if (DownloadedFiles.Count <= 0) return; - if (SelectedDownloadedFileIndex <= -1) return; - var filePath = DownloadedFiles[SelectedDownloadedFileIndex].FilePath; + public void OpenFile() + { + if (DownloadedFiles.Count <= 0) + return; + + var fileModel = + SelectedDownloadedFileIndex > -1 ? DownloadedFiles[SelectedDownloadedFileIndex] : SelectedDownloadedFile; + + if (fileModel == null) + return; + + var filePath = fileModel.FilePath; using var process = new Process(); - process.StartInfo = new ProcessStartInfo(filePath) { - UseShellExecute = true - }; + process.StartInfo = new ProcessStartInfo(filePath) { UseShellExecute = true }; process.Start(); } public RelayCommand Process; - public async Task ProcessCommand() => await ProcessHelper.HandleProcess(this, Url!); - [RelayCommand] public async Task UploadFiles(string role) => await new UploadService().UploadEditedFiles(role); + public async Task ProcessCommand() => await _workflowService.HandleProcess(this, Url!); + + [RelayCommand] + public async Task UploadFiles(string role) => await _fileOpsService.UploadEditedFiles(this, role); [RelayCommand] - public void DeleteSelectedFile() { - if (SelectedDownloadedFileIndex < 0 || SelectedDownloadedFileIndex >= DownloadedFiles.Count) + public void DeleteSelectedFile() + { + DownloadModel? selectedFile = null; + + if (SelectedDownloadedFileIndex >= 0 && SelectedDownloadedFileIndex < DownloadedFiles.Count) + { + selectedFile = DownloadedFiles[SelectedDownloadedFileIndex]; + } + else if (SelectedDownloadedFile != null) + { + selectedFile = SelectedDownloadedFile; + } + + if (selectedFile == null) return; - var selectedFile = DownloadedFiles[SelectedDownloadedFileIndex]; - if (File.Exists(selectedFile.FilePath)) - File.Delete(selectedFile.FilePath); - DownloadedFiles.RemoveAt(SelectedDownloadedFileIndex); - var appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ChemLocalLink"); + string? fileDirectory = null; + try + { + if (File.Exists(selectedFile.FilePath)) + { + fileDirectory = Path.GetDirectoryName(selectedFile.FilePath); + File.Delete(selectedFile.FilePath); + } + var backup = selectedFile.FilePath + "~"; + if (File.Exists(backup)) + File.Delete(backup); + } + catch { } + + DownloadedFiles.Remove(selectedFile); + + if (fileDirectory != null) + { + CleanupEmptyDirectories(fileDirectory); + } + + var appDataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ChemLocalLink" + ); Directory.CreateDirectory(appDataPath); var jsonFilePath = Path.Combine(appDataPath, "downloads.json"); - if (File.Exists(jsonFilePath) && !string.IsNullOrEmpty(File.ReadAllText(jsonFilePath))) { - var data = JsonConvert.SerializeObject(DownloadedFiles); + if (File.Exists(jsonFilePath) && !string.IsNullOrEmpty(File.ReadAllText(jsonFilePath))) + { + var data = JsonConvert.SerializeObject(DownloadedFiles, Formatting.Indented); File.WriteAllText(jsonFilePath, data); } HasFilesDownloaded = DownloadedFiles.Count > 0; + + RebuildGroups(); + } + + [RelayCommand] + public async Task ExportSession(string targetPath) + { + return await _sessionService.ExportSessionAsync(this, targetPath); + } + + [RelayCommand] + public async Task ImportSession(string sourcePath) + { + return await _sessionService.ImportSessionAsync(this, sourcePath); } - partial void OnStatusChanged(string? oldValue, string? newValue) { - Task.Run(async () => { + [RelayCommand] + public void SelectFile(DownloadModel file) + { + SelectedDownloadedFile = file; + } + + [RelayCommand] + public async Task DuplicateAndRenameFile() + { + if (SelectedDownloadedFile == null) + return; + + var originalName = Path.GetFileNameWithoutExtension(SelectedDownloadedFile.FileName); + var extension = Path.GetExtension(SelectedDownloadedFile.FileName); + var suggestedName = $"{originalName}_copy{extension}"; + + var result = await _fileOpsService.DuplicateAndRenameFile(this, SelectedDownloadedFile, suggestedName); + + if (result != null) + { + Status = $"File duplicated successfully as '{result.FileName}'"; + } + } + + [RelayCommand] + public async Task ScanFolderForNewFiles() + { + await _fileOpsService.ScanFolderForNewFiles(this); + } + + partial void OnStatusChanged(string? oldValue, string? newValue) + { + Task.Run(async () => + { await Task.Delay(10000); Status = ""; }); } + + public void Dispose() + { + fileMonitorTimer?.Stop(); + fileMonitorTimer?.Dispose(); + idleTimer?.Stop(); + _fileProcess?.Dispose(); + } + + partial void OnDownloadedFilesChanged(ObservableCollection value) + { + RebuildGroups(); + } + + private int _rebuildVersion = 0; + + public async void RebuildGroups() + { + try + { + var currentVersion = ++_rebuildVersion; + + // small debounce for rapid updates + await Task.Delay(10); + if (currentVersion != _rebuildVersion) + return; + + await Dispatcher.UIThread.InvokeAsync(() => + { + var groups = DownloadedFiles + .GroupBy(d => (d.Origin ?? string.Empty).Trim().ToLowerInvariant()) + .Select(g => + { + var originName = string.IsNullOrWhiteSpace(g.Key) + ? "Unknown" + : g.Select(x => x.Origin).FirstOrDefault(o => !string.IsNullOrWhiteSpace(o)) ?? g.Key; + + var originGroup = new OriginGroupModel { Origin = originName }; + + var filesByPath = g.GroupBy(f => f.Path ?? string.Empty).ToList(); + + var filesWithoutPaths = + filesByPath.FirstOrDefault(fp => string.IsNullOrEmpty(fp.Key))?.ToList() ?? new List(); + foreach (var file in filesWithoutPaths.OrderByDescending(f => f.FileDownloadTimeStamp)) + { + originGroup.Files.Add(file); + } + + var filesWithPaths = filesByPath.Where(fp => !string.IsNullOrEmpty(fp.Key)).ToList(); + if (filesWithPaths.Any()) + { + BuildFolderHierarchy(originGroup, filesWithPaths); + } + + return originGroup; + }) + .OrderBy(g => g.Origin) + .ToList(); + + DownloadedByOrigin.Clear(); + foreach (var g in groups) + { + DownloadedByOrigin.Add(g); + } + }); + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + } + } + + private void BuildFolderHierarchy(OriginGroupModel originGroup, List> filesWithPaths) + { + foreach (var pathGroup in filesWithPaths) + { + var fullPath = pathGroup.Key; + var files = pathGroup.OrderByDescending(f => f.FileDownloadTimeStamp).ToList(); + + var collapsedFolder = new FolderModel + { + Name = fullPath, + FullPath = fullPath, + IsCollapsed = false + }; + foreach (var f in files) + { + collapsedFolder.Files.Add(f); + } + + originGroup.Folders.Add(collapsedFolder); + } + } + + partial void OnHasFilesDownloadedChanged(bool value) + { + RebuildGroups(); + } + + [RelayCommand] + public async Task ClearAllDownloads() + { + try + { + var directoriesToCheck = new HashSet(); + + foreach (var d in DownloadedFiles.ToList()) + { + try + { + if (File.Exists(d.FilePath)) + { + var fileDirectory = Path.GetDirectoryName(d.FilePath); + if (fileDirectory != null) + { + directoriesToCheck.Add(fileDirectory); + } + File.Delete(d.FilePath); + } + + var backup = d.FilePath + "~"; + if (File.Exists(backup)) + File.Delete(backup); + } + catch { } + } + DownloadedFiles.Clear(); + HasFilesDownloaded = false; + await _jsonDataService.WriteDataToAppData(this); + + // Clean up empty directories + foreach (var directory in directoriesToCheck) + { + CleanupEmptyDirectories(directory); + } + + // purge any stray backup files + try + { + var dir = _pathService.GetDownloadDirectory(); + if (Directory.Exists(dir)) + { + foreach (var orphan in Directory.EnumerateFiles(dir, "*~", SearchOption.TopDirectoryOnly)) + { + try + { + File.Delete(orphan); + } + catch { } + } + } + } + catch { } + + Status = "All downloads cleared"; + } + catch + { + Status = "Clear failed"; + } + } + + private void CleanupEmptyDirectories(string startDirectory) + { + try + { + var downloadRoot = _pathService.GetDownloadDirectory(); + var currentDir = startDirectory; + + while ( + !string.IsNullOrEmpty(currentDir) + && currentDir.Length > downloadRoot.Length + && currentDir.StartsWith(downloadRoot, StringComparison.OrdinalIgnoreCase) + ) + { + try + { + if (Directory.Exists(currentDir)) + { + var files = Directory.GetFiles(currentDir); + var subdirs = Directory.GetDirectories(currentDir); + + if (files.Length == 0 && subdirs.Length == 0) + { + Directory.Delete(currentDir); + currentDir = Path.GetDirectoryName(currentDir); + } + else + { + break; + } + } + else + { + currentDir = Path.GetDirectoryName(currentDir); + } + } + catch + { + break; + } + } + } + catch { } + } } diff --git a/Views/DownloadsView.axaml b/Views/DownloadsView.axaml new file mode 100644 index 0000000..64016a4 --- /dev/null +++ b/Views/DownloadsView.axaml @@ -0,0 +1,432 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Views/DownloadsView.axaml.cs b/Views/DownloadsView.axaml.cs new file mode 100644 index 0000000..42a38b8 --- /dev/null +++ b/Views/DownloadsView.axaml.cs @@ -0,0 +1,15 @@ +/// +/// Code-behind for Downloads user control displaying downloaded files +/// + +using Avalonia.Controls; + +namespace ChemLocalLink.Views; + +public partial class DownloadsView : UserControl +{ + public DownloadsView() + { + InitializeComponent(); + } +} diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml deleted file mode 100644 index d645bd0..0000000 --- a/Views/MainWindow.axaml +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + +