diff --git a/src/MayShow.Shared/App.axaml b/src/MayShow.Shared/App.axaml index 562527a..1d86e47 100644 --- a/src/MayShow.Shared/App.axaml +++ b/src/MayShow.Shared/App.axaml @@ -85,8 +85,8 @@ - - + + diff --git a/src/MayShow.Shared/App.axaml.cs b/src/MayShow.Shared/App.axaml.cs index 2936c5a..5b599b2 100644 --- a/src/MayShow.Shared/App.axaml.cs +++ b/src/MayShow.Shared/App.axaml.cs @@ -1,31 +1,48 @@ using System; using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using DialogHostAvalonia; using MayShow; +using MayShow.Interfaces; +using MayShow.Views; using MayShow.ViewModels; namespace MayShow; -public partial class App : Application +public partial class App : Application, ITopLevelGrabber { public override void Initialize() { AvaloniaXamlLoader.Load(this); } + private TopLevel? _topLevel; + public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new MainWindow(); } + else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) + { + // singleViewPlatform.MainView = new MainView(); + // _topLevel = singleViewPlatform.MainView as TopLevel; + //_topLevel = TopLevel.GetTopLevel(singleViewPlatform.MainView); + //singleViewPlatform.MainView.DataContext = new MainViewModel(this); + } base.OnFrameworkInitializationCompleted(); } + public TopLevel GetTopLevel() + { + return _topLevel; + } + public void AboutOnClick(object? sender, EventArgs args) { DialogHost.Show(new AboutViewModel()); diff --git a/src/MayShow.Shared/Helpers/DataGridDropHandler.cs b/src/MayShow.Shared/Helpers/DataGridDropHandler.cs index 7c6e426..2f6cbac 100644 --- a/src/MayShow.Shared/Helpers/DataGridDropHandler.cs +++ b/src/MayShow.Shared/Helpers/DataGridDropHandler.cs @@ -15,7 +15,7 @@ class DataGridDropHandler : BaseDataGridDropHandler protected override bool Validate(DataGrid dg, DragEventArgs e, object? sourceContext, object? targetContext, bool execute) { if (sourceContext is not ReportFile sourceItem - || targetContext is not MainViewModel vm + || targetContext is not CreatePDFReportViewModel vm || dg.GetVisualAt(e.GetPosition(dg)) is not Control targetControl || targetControl.DataContext is not ReportFile targetItem) { diff --git a/src/MayShow.Shared/MainWindow.axaml b/src/MayShow.Shared/MainWindow.axaml index 3867d1d..75f6e5c 100644 --- a/src/MayShow.Shared/MainWindow.axaml +++ b/src/MayShow.Shared/MainWindow.axaml @@ -5,20 +5,13 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="MayShow.MainWindow" Title="MayShow" + xmlns:views="clr-namespace:MayShow.Views" xmlns:vm="clr-namespace:MayShow.ViewModels" xmlns:dialogHost="clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia" - x:DataType="vm:MainWindowViewModel" + x:DataType="vm:MainViewModel" Width="800" MinWidth="550" Height="650" MinHeight="550"> - - - - - - - + diff --git a/src/MayShow.Shared/MainWindow.axaml.cs b/src/MayShow.Shared/MainWindow.axaml.cs index 33f39fc..3b289eb 100644 --- a/src/MayShow.Shared/MainWindow.axaml.cs +++ b/src/MayShow.Shared/MainWindow.axaml.cs @@ -14,7 +14,7 @@ public partial class MainWindow : Window, ITopLevelGrabber public MainWindow() { InitializeComponent(); - DataContext = new MainWindowViewModel(this); + DataContext = new MainViewModel(this); Closing += WindowIsClosing; @@ -49,14 +49,14 @@ public partial class MainWindow : Window, ITopLevelGrabber private async Task CheckIfClosePossible() { var canShutdown = true; - if (DataContext is MainWindowViewModel mwvm) + if (DataContext is MainViewModel mvm) { - if (mwvm is ICanCheckShutdown canCheck) + if (mvm is ICanCheckShutdown canCheck) { canShutdown = await canCheck.CheckIsSafeToShutdown(); } // only checking 1 level but for this app that is OK - if (canShutdown && mwvm.CurrentViewModel is ICanCheckShutdown currModel) + if (canShutdown && mvm.CurrentViewModel is ICanCheckShutdown currModel) { try { diff --git a/src/MayShow.Shared/MayShow.Shared.csproj b/src/MayShow.Shared/MayShow.Shared.csproj index 2704ca2..774652e 100644 --- a/src/MayShow.Shared/MayShow.Shared.csproj +++ b/src/MayShow.Shared/MayShow.Shared.csproj @@ -5,13 +5,12 @@ enable true true - true - true - true - true MayShow 1.4.0 + + true + @@ -27,21 +26,27 @@ Always + Resource Always + Resource Always + Resource Always + Resource Always + Resource Always + Resource diff --git a/src/MayShow.Shared/ViewModels/CreatePDFReportViewModel.cs b/src/MayShow.Shared/ViewModels/CreatePDFReportViewModel.cs new file mode 100644 index 0000000..9800706 --- /dev/null +++ b/src/MayShow.Shared/ViewModels/CreatePDFReportViewModel.cs @@ -0,0 +1,919 @@ +#nullable enable + +using System; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Avalonia.Platform.Storage; +using Avalonia.Themes.Fluent; +using DialogHostAvalonia; +using ImageMagick; +using MigraDoc.DocumentObjectModel; +using MigraDoc.Rendering; +using PdfSharp.Fonts; +using PdfSharp.Pdf.IO; +using PdfSharp.Snippets.Font; +using MayShow.Helpers; +using MayShow.Interfaces; +using MayShow.Models; +using MayShows.Helpers; + +using Docnet.Core.Models; +using Docnet.Core; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Reflection.Metadata.Ecma335; +using Docnet.Core.Readers; + +namespace MayShow.ViewModels; + +class CreatePDFReportViewModel : BaseViewModel, IFontResolver, ICanCheckShutdown +{ + private bool _isPerformingInitialLoad; + private string _processDir; + private bool _isCreatingPDF; + private string _programLog; + private string _workingFolder; + + private string _reportTitle; + private ObservableCollection _reportFiles; + private DateTime? _lastGeneratedTime; + + private Settings _settings; + + private bool _hasUnsavedWork; + + public CreatePDFReportViewModel(IChangeViewModel viewModelChanger) : base(viewModelChanger) + { + _isPerformingInitialLoad = true; + _processDir = Path.GetDirectoryName(Environment.ProcessPath) ?? ""; + Console.WriteLine("Internal storage directory is: {0}", Utilities.GetInternalDataPath()); + _isCreatingPDF = false; + var quotes = Constants.GetQuotes(); + Random random = new Random(); + var quoteIndex = random.Next(0, quotes.Length); + _programLog = "----- MayShow v" + Constants.AppVersion + " ------" + Environment.NewLine; + _programLog += quotes[quoteIndex] + Environment.NewLine; + _programLog += "---------------------------------------" + Environment.NewLine; + _programLog += "Loaded and ready to create report!" + Environment.NewLine; + _programLog += "Please copy and send this Program Log when reporting any issues with the software."; + _workingFolder = ""; + ReportFiles = _reportFiles = new ObservableCollection(); + _reportTitle = ""; + _lastGeneratedTime = null; + _settings = Settings.LoadSettings(); + if (!string.IsNullOrWhiteSpace(_settings.LastUsedPath)) + { + LogInfo("Loading data at last used path of {0}", _settings.LastUsedPath); + ScanFolder(_settings.LastUsedPath); + } + else + { + LogInfo("Choose a receipt folder to begin..."); + } + HasUnsavedWork = false; + _isPerformingInitialLoad = false; + } + + public string ReportTitle + { + get => _reportTitle; + set + { + _reportTitle = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(IsTitleBoxVisible)); + NotifyPropertyChanged(nameof(CanAddItem)); + } + } + + public bool IsTitleBoxVisible + { + get => !string.IsNullOrWhiteSpace(WorkingFolder); + } + + public bool CanAddItem + { + get => IsTitleBoxVisible && !IsCreatingPDF; + } + + public bool IsCreatingPDF + { + get => _isCreatingPDF; + set + { + _isCreatingPDF = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); + NotifyPropertyChanged(nameof(HasWorkingFolderAndNotMakingPDF)); + NotifyPropertyChanged(nameof(CanAddItem)); + } + } + + public bool IsCreatePDFButtonEnabled + { + get => !_isCreatingPDF && _reportFiles.Count > 0; + } + + public bool HasWorkingFolder + { + get => !string.IsNullOrWhiteSpace(WorkingFolder) && Directory.Exists(WorkingFolder); + } + + public bool HasWorkingFolderAndNotMakingPDF + { + get => !string.IsNullOrWhiteSpace(WorkingFolder) && Directory.Exists(WorkingFolder) && !_isCreatingPDF; + } + + public string WorkingFolder + { + get => _workingFolder; + set + { + _workingFolder = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(HasWorkingFolder)); + NotifyPropertyChanged(nameof(HasWorkingFolderAndNotMakingPDF)); + } + } + + public string ProgramLog + { + get => _programLog; + set { _programLog = value; NotifyPropertyChanged(); } + } + + public bool HasUnsavedWork + { + get => _hasUnsavedWork; + set + { + _hasUnsavedWork = value; + NotifyPropertyChanged(); + } + } + + public ObservableCollection ReportFiles + { + get => _reportFiles; + set + { + _reportFiles = value; + NotifyPropertyChanged(); + _reportFiles.CollectionChanged += ( sender, e ) => + { + NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); + HasUnsavedWork = true; + }; + } + } + + private void LogInfo(string message, params object[]? arguments) + { + var timestamp = string.Format("[{0:s}]", DateTime.Now); + Console.WriteLine(timestamp + " " + message, arguments); + ProgramLog += Environment.NewLine + string.Format(message, arguments ?? []); + } + + public async void ChooseFolder() + { + var topLevel = TopLevelGrabber?.GetTopLevel(); + if (topLevel is not null) + { + var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions() + { + Title = "Pick a folder of files...", + AllowMultiple = false, + }); + if (folders.Count == 1) + { + var folder = folders[0]; + LogInfo("Clearing existing list and loading items in folder: " + folder.Path.LocalPath); + ReportFiles.Clear(); + ScanFolder(folder.Path.LocalPath); + _settings.LastUsedPath = folder.Path.LocalPath; + await _settings.SaveSettingsAsync(); + ResortPDFItemsByDate(); + HasUnsavedWork = true; + } + } + } + + private string GetReportSavedDataPath(string folderPath) + { + if (_settings.SaveReportJsonDataInInternalDir) + { + var internalPath = Utilities.GetInternalDataPath(); + if (!_settings.WorkingFolderToInternalFolderName.ContainsKey(folderPath)) + { + var uuid = ""; + var potentialPath = ""; + var isDone = false; + // make sure uuid not already used...just in case...because paranoia... + do + { + uuid = Guid.NewGuid().ToString(); + potentialPath = Path.Combine(internalPath, uuid); + isDone = !Directory.Exists(potentialPath); + } while (!isDone); + // make internal dir -- using dir so we have option to copy data into dir later if needed + // (if we ever implement a more robust report system where we keep all files) + Directory.CreateDirectory(potentialPath); + _settings.WorkingFolderToInternalFolderName[folderPath] = uuid; + _settings.SaveSettingsNotAsync(); // save new key/value pair + } + return Path.Combine( + internalPath, + _settings.WorkingFolderToInternalFolderName[folderPath], + Constants.ReportSavedDataFileName + ); + } + else + { + return Path.Combine(folderPath, Constants.ReportSavedDataFileName); + } + } + + private void ScanFolder(string path) + { + if (Directory.Exists(path)) + { + WorkingFolder = path; + NotifyPropertyChanged(nameof(IsTitleBoxVisible)); + NotifyPropertyChanged(nameof(CanAddItem)); + var reportFilePath = GetReportSavedDataPath(path); + var successfullyLoadedPriorReport = false; + if (File.Exists(reportFilePath)) + { + // load prior report + var json = File.ReadAllText(reportFilePath); + var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); + var report = JsonSerializer.Deserialize(json, jsonContext.PDFReport); + if (report != null && report.Files.Count > 0) + { + Console.WriteLine("Loading prior report data at {0}", reportFilePath); + ReportFiles = new ObservableCollection(report.Files); + ReportTitle = report.Title; + WorkingFolder = report.BaseFolder; + _lastGeneratedTime = report.LastGenerated ?? null; + LogInfo("Reloaded report last saved at {0}", report.LastSaved); + successfullyLoadedPriorReport = true; + } + } + if (!successfullyLoadedPriorReport) + { + // Scan folder for files and display in DataGrid + ReportFiles.Clear(); + ReportTitle = ""; + var filePaths = Directory.GetFiles(WorkingFolder); + foreach (var filePath in filePaths) + { + AddFileBasedOnPath(filePath); + } + ResortPDFItemsByDate(); + HasUnsavedWork = true; + } + } + else + { + LogInfo("Error: The directory {0} does not exist. Please select another folder.", path); + } + NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); + } + + public void ShowAbout() + { + DialogHost.Show(new AboutViewModel()); + } + + public async Task ShowSettings() + { + var updatedSettings = await DialogHost.Show(new SettingsViewModel(_settings, TopLevelGrabber)); + if (updatedSettings != null) + { + _settings = (Settings)updatedSettings; + await _settings.SaveSettingsAsync(); + LogInfo("Saved updated settings!"); + } + } + + public void RemoveFile(object f) => RemoveFileImpl((ReportFile)f); + + public async void RemoveFileImpl(ReportFile file) + { + var result = await DialogHost.Show(new WarningDeleteItemViewModel(file)); + if (result != null && (bool)result) + { + var idx = ReportFiles.IndexOf(file); + if (idx != -1) + { + ReportFiles.RemoveAt(idx); + HasUnsavedWork = true; + } + } + } + + // https://github.com/AvaloniaUI/Avalonia/issues/10075 + public void EditFileProperties(object f) => EditFilePropertiesImpl((ReportFile)f); + + public async void EditFilePropertiesImpl(ReportFile file) + { + var result = await DialogHost.Show(new EditFileViewModel(file, ViewModelChanger)); + if (result != null && result is ReportFile updatedData) + { + file.Title = updatedData.Title; + file.ReceiptDateTime = updatedData.ReceiptDateTime; + file.Notes = updatedData.Notes; + HasUnsavedWork = true; + } + } + + public async void AddItem() + { + var topLevel = TopLevelGrabber?.GetTopLevel(); + if (topLevel is not null) + { + var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions() + { + Title = "Choose image or PDF files...", + AllowMultiple = true, + FileTypeFilter = [ + new FilePickerFileType("All Types") + { + Patterns = Constants.AllowedFileExtensionPatterns, + AppleUniformTypeIdentifiers = [ "public.image", "com.adobe.pdf", "public.heic" ], + MimeTypes = [ "image/*", "application/pdf", "image/heic" ] + }, + FilePickerFileTypes.ImageAll, + new FilePickerFileType("HEIC Images") + { + Patterns = [ "*.heic" ], + AppleUniformTypeIdentifiers = [ "public.heic" ], + MimeTypes = [ "image/heic" ] + }, + FilePickerFileTypes.Pdf, + ], + }); + if (files.Count > 0) + { + foreach (var file in files) + { + var filePath = file.TryGetLocalPath(); + AddFileBasedOnPath(filePath); + } + } + } + } + + private void AddFileBasedOnPath(string? filePath) + { + if (!string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath) && !filePath.EndsWith(".DS_Store")) + { + // make sure extensions are OK + var fileExtensions = Constants.AllowedFileExtensionsNoStar; + var didMatch = false; + foreach (var fileExtension in fileExtensions) + { + if (filePath.ToLower().EndsWith("." + fileExtension.ToLower())) + { + didMatch = true; + break; + } + } + if (!didMatch) + { + if (!filePath.EndsWith(Constants.ReportSavedDataFileName)) + { + LogInfo("File {0} did not match allowed file extension types, so it was not added.", filePath); + } + } + else + { + var date = Utilities.CheckValidDateInString(filePath); + ReportFiles.Add(new ReportFile() + { + Title = Path.GetFileName(filePath), + ReceiptDateTime = date.HasValue ? date.Value.ToDateTime(TimeOnly.MinValue) : File.GetCreationTime(filePath), + Notes = "", + FilePath = filePath, + }); + HasUnsavedWork = true; + } + } + } + + public async void RemoveAllItems() + { + var result = await DialogHost.Show(new ConfirmViewModel("Warning!", "Are you sure you want to remove all items from this report?", "Remove All Items", "Cancel")); + if (result != null && (bool)result) + { + ReportFiles.Clear(); + HasUnsavedWork = true; + NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); + } + } + + public void LocateFile(object f) => LocateFileImpl((ReportFile) f); + public async void LocateFileImpl(ReportFile reportFile) + { + var topLevel = TopLevelGrabber?.GetTopLevel(); + if (topLevel is not null) + { + var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions() + { + Title = "Choose image or PDF file...", + AllowMultiple = false, + FileTypeFilter = [ + new FilePickerFileType("All Types") + { + Patterns = Constants.AllowedFileExtensionPatterns, + AppleUniformTypeIdentifiers = [ "public.image", "com.adobe.pdf", "public.heic" ], + MimeTypes = [ "image/*", "application/pdf", "image/heic" ] + }, + FilePickerFileTypes.ImageAll, + new FilePickerFileType("HEIC Images") + { + Patterns = [ "*.heic" ], + AppleUniformTypeIdentifiers = [ "public.heic" ], + MimeTypes = [ "image/heic" ] + }, + FilePickerFileTypes.Pdf, + ], + }); + if (files.Count > 0) + { + var file = files[0]; + reportFile.FilePath = file.Path.LocalPath; + HasUnsavedWork = true; + } + } + } + + // https://github.com/AvaloniaUI/Avalonia/issues/10075 + public void OpenFile(object f) => OpenFileImpl((ReportFile)f); + public void OpenFileImpl(ReportFile file) + { + var topLevel = TopLevelGrabber?.GetTopLevel(); + if (topLevel is not null) + { + var launcher = topLevel.Launcher; + launcher.LaunchUriAsync(new Uri(file.FilePath)); + } + } + + public void OpenFileLocation(object f) => OpenFileLocationImpl((ReportFile)f); + + private void OpenFileLocationImpl(ReportFile file) + { + OpenFolderForFileInFileViewer(file.FilePath); + } + + private void OpenFolderForFileInFileViewer(string fullPathToFile) + { + var topLevel = TopLevelGrabber?.GetTopLevel(); + var dirName = Path.GetDirectoryName(fullPathToFile); + if (topLevel is not null && dirName != null) + { + var launcher = topLevel.Launcher; + launcher.LaunchUriAsync(new Uri(dirName)); + } + } + + public void ResortPDFItemsByDate() + { + LogInfo("Sorting report files list..."); + ReportFiles = new ObservableCollection(ReportFiles.OrderBy(x => x.ReceiptDateTime)); + HasUnsavedWork = true; + } + + public async void BuildPDF() + { + if (string.IsNullOrWhiteSpace(ReportTitle)) + { + await DialogHost.Show(new WarningViewModel("You must provide a report title!")); + } + else + { + try + { + await Task.Run(() => CreatePDF(WorkingFolder)); + } catch (Exception e) + { + LogInfo("PDF process failed! Reason: " + e.Message); + if (e.StackTrace != null) + { + LogInfo(e.StackTrace); + } + if (e.InnerException != null) + { + LogInfo("Inner exception: " + e.InnerException.Message); + if (e.InnerException.StackTrace != null) + { + LogInfo(e.InnerException.StackTrace); + } + } + LogInfo("Please report this error to a programmer or fix the issue listed above."); + IsCreatingPDF = false; + } + } + } + + public async Task SaveInterimReportInfo() + { + var report = new PDFReport() + { + Title = ReportTitle, + Files = ReportFiles.ToList(), + BaseFolder = WorkingFolder, + LastSaved = DateTime.Now, + LastGenerated = _lastGeneratedTime, + }; + await SavePDFReportDataToDisk(report); + } + + private async Task SavePDFReportDataToDisk(PDFReport report) + { + var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); + using var memoryStream = new MemoryStream(); + await JsonSerializer.SerializeAsync(memoryStream, report, jsonContext.PDFReport); + memoryStream.Position = 0; + using var reader = new StreamReader(memoryStream); + var json = await reader.ReadToEndAsync(); + var savePath = GetReportSavedDataPath(WorkingFolder); + await File.WriteAllTextAsync(savePath, json); + LogInfo("Saved report information to {0}", savePath); + HasUnsavedWork = false; + } + + private async Task CreateAndSaveReportObjectAfterReportCreation() + { + var report = new PDFReport() + { + Title = ReportTitle, + Files = ReportFiles.ToList(), + BaseFolder = WorkingFolder, + LastSaved = DateTime.Now, + LastGenerated = DateTime.Now, + }; + _lastGeneratedTime = DateTime.Now; + await SavePDFReportDataToDisk(report); + } + + public async Task CopyLogToClipboard() + { + var clipboard = TopLevelGrabber?.GetTopLevel().Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(ProgramLog); + LogInfo("Program log has been copied to the clipboard!"); + } + } + + public byte[]? GetFont(string faceName) + { + LogInfo(string.Format("Loading font {0}", faceName)); + if (faceName == "Noto Sans JP") + { + var path = Path.Combine(_processDir, "Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf"); + if (!File.Exists(path)) + { + path = Path.Combine(_processDir, "../Resources/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf"); + } + return File.ReadAllBytes(path); + } + if (faceName == "Noto Sans JP Bold") + { + var path = Path.Combine(_processDir, "Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf"); + if (!File.Exists(path)) + { + path = Path.Combine(_processDir, "../Resources/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf"); + } + return File.ReadAllBytes(path); + } + return null; + } + + public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic) + { + // LogInfo(string.Format("Resolving font name {0}", familyName)); + if (familyName == "Noto Sans JP") + { + if (bold) + { + return new FontResolverInfo(familyName + " Bold"); + } + return new FontResolverInfo(familyName); + } + return null; + } + + // https://forum.pdfsharp.net/viewtopic.php?f=2&t=1025 + private async Task CreatePDF(string folderPath) + { + // TODO: calculate needed width for images based on page width and margins and all that? + // safety checks + var outputDir = _settings.SaveOutputPdfInWorkingDir ? folderPath : _settings.OutputPdfDir; + if (!Directory.Exists(outputDir)) + { + await DialogHost.Show(new WarningViewModel("Output directory not found! Please adjust your application Settings before continuing. Output directory: " + outputDir)); + return; + } + // start making PDF! + IsCreatingPDF = true; + var pdfDoc = new Document(); + var outputFileName = ReportTitle + ".pdf"; + var folderName = new DirectoryInfo(folderPath).Name; + const int imageWidth = 425; + if (folderName.Contains('-')) + { + // see if year/month format + var parts = folderName.Split('-'); + if (parts[0].Length == 4 && + parts[1].Length <= 2 && + int.TryParse(parts[0], out int year) && int.TryParse(parts[1], out int month)) + { + outputFileName = string.Format("{0} {1} Receipts.pdf", + CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(month), + year); + LogInfo("Auto-changed output file name to " + outputFileName); + } + } + var section = pdfDoc.AddSection(); + section.PageSetup.PageFormat = PageFormat.Letter; + section.PageSetup.PageWidth = "8.5in"; + section.PageSetup.PageHeight = "11in"; + section.PageSetup.TopMargin = "0.5in"; + section.PageSetup.RightMargin = "0.5in"; + section.PageSetup.BottomMargin = "0.5in"; + section.PageSetup.LeftMargin = "0.5in"; + // setup footer for page number + var footerPar = new Paragraph(); + footerPar.Format.Alignment = ParagraphAlignment.Center; + footerPar.Format.Font.Size = 10; + footerPar.AddText("--Page "); + footerPar.AddPageField(); + footerPar.AddText(" of "); + footerPar.AddNumPagesField(); + footerPar.AddText("--"); + footerPar.AddLineBreak(); + footerPar.AddText("Report generated on " + DateTime.Now.ToString("f")); + section.Footers.Primary.Add(footerPar); + // add report title + var reportTitlePar = section.AddParagraph(); + reportTitlePar.Format.Alignment = ParagraphAlignment.Center; + reportTitlePar.Format.Font.Size = 16; + reportTitlePar.Format.Font.Bold = true; + reportTitlePar.Format.Font.Name = "Noto Sans JP"; // has english letters in it, too + reportTitlePar.AddText(ReportTitle); + // get converted files directory path and create it if necessary + var convertedDir = Path.Combine(Utilities.GetInternalDataPath(), "converted"); + if (!Directory.Exists(convertedDir)) + { + Directory.CreateDirectory(convertedDir); + } + // + GlobalFontSettings.FontResolver = this; + GlobalFontSettings.FallbackFontResolver = new FailsafeFontResolver(); + var hasAddedData = false; + for (var i = 0; i < ReportFiles.Count; i++) + { + var file = ReportFiles[i]; + var fileName = file.FileName; + var filePath = file.FilePath; + if (!File.Exists(filePath)) + { + LogInfo("ERROR: File \"{0}\" does not exist at path \"{1}\". Please remove it from the report or re-add it using the Add Item button if you still want it to be in this report.", file.Title, file.FilePath); + IsCreatingPDF = false; + return; + } + if (fileName == ".DS_Store" || fileName == outputFileName) + { + continue; + } + if (i > 0 && hasAddedData) + { + section.AddPageBreak(); + } + var imageTitlePar = section.AddParagraph(); + imageTitlePar.Format.Alignment = ParagraphAlignment.Center; + imageTitlePar.Format.Font.Size = 12; + imageTitlePar.Format.Font.Bold = true; + imageTitlePar.Format.Font.Name = "Noto Sans JP"; // has english letters in it, too + imageTitlePar.AddText(string.IsNullOrWhiteSpace(file.Title) ? file.FileName : file.Title); + var receiptDatePar = section.AddParagraph(); + receiptDatePar.Format.Alignment = ParagraphAlignment.Center; + receiptDatePar.Format.Font.Size = 12; + receiptDatePar.Format.Font.Bold = true; + receiptDatePar.Format.Font.Name = "Noto Sans JP"; // has english letters in it, too + receiptDatePar.AddText(file.ReceiptDate.ToString("yyyy-MM-dd")); + if (!string.IsNullOrWhiteSpace(file.Notes)) + { + var imageNotesPar = section.AddParagraph(); + imageNotesPar.Format.Alignment = ParagraphAlignment.Center; + imageNotesPar.Format.Font.Size = 10; + imageNotesPar.Format.Font.Bold = false; + imageNotesPar.Format.Font.Name = "Noto Sans JP"; + imageNotesPar.AddText(file.Notes); + } + section.AddParagraph(); // add empty line for spacing + // now add the image + var lowerName = fileName.ToLower(); + var isPDF = lowerName.EndsWith(".pdf"); + // convert heic, webp, or png to JPEG for size and ease of use + // (and probably compat reasons too, though I haven't tested that...) + var isHEIC = lowerName.EndsWith(".heic"); + var isWebp = lowerName.EndsWith(".webp"); + var isPNG = lowerName.EndsWith(".png"); + var info = new FileInfo(file.FilePath); + uint loadedImageWidth = 0; + uint loadedImageHeight = 0; + if (!isPDF) + { + using var mImage = new MagickImage(info.FullName); + loadedImageWidth = mImage.Width; + loadedImageHeight = mImage.Height; + var convertedOutputPath = Path.Combine(convertedDir, info.Name + ".jpg"); + var didAdjust = false; + LogInfo("Image orientation of {0} is {1}", fileName, mImage.Orientation); + if (mImage.Orientation != OrientationType.TopLeft) + { + LogInfo("Auto-adjusted image orientation of {0}", fileName); + mImage.AutoOrient(); + didAdjust = true; + } + // perform needed image manipulations + if (isHEIC || isWebp || isPNG || (!isPDF && info.Length > _settings.ImageResizeThreshold * 1024 * 1024)) + { + // Save image as jpg + mImage.Quality = 80; + if (mImage.Width >= 400 || mImage.Height >= 400) + { + loadedImageWidth = (uint)Math.Floor(mImage.Width * 0.5); + loadedImageHeight = (uint)Math.Floor(mImage.Height * 0.5); + mImage.Scale(loadedImageWidth, loadedImageHeight); + LogInfo("Image {2} scaled to {0}x{1}", loadedImageWidth, loadedImageHeight, fileName); + } + didAdjust = true; + LogInfo("Converted image {0} to JPEG", fileName); + } + else + { + // load height/width + loadedImageWidth = mImage.Width; + loadedImageHeight = mImage.Height; + } + if (didAdjust) + { + await mImage.WriteAsync(convertedOutputPath); + filePath = convertedOutputPath; + LogInfo(string.Format("Saved adjusted image to JPEG; file path is now {0}", filePath)); + } + // write to PDF + var paragraph = section.AddParagraph(); + paragraph.Format.Alignment = ParagraphAlignment.Center; + var image = paragraph.AddImage(filePath); + image.LockAspectRatio = true; + if (!isPDF && loadedImageHeight > 600) + { + image.Height = 550; // make sure it will fit on one page + } + else + { + image.Width = imageWidth; // can't be too wide now...not sure why...maybe due to margins... + } + } + else + { + // need to render PDF to images + if (_settings.UseDocnetPDFImageRendering) + { + // render using Docnet library (which utilizes pdfium, the chrome renderer) + string RenderPdfPageToImage(IDocReader docReader, int pgNum) + { + Console.WriteLine("Rendering pg " + pgNum); + using var pageReader = docReader.GetPageReader(pgNum); + Console.WriteLine("Getting image for page " + pgNum); + var rawBytes = pageReader.GetImage(RenderFlags.RenderAnnotations); + Console.WriteLine("Getting width & height for page " + pgNum); + var width = pageReader.GetPageWidth(); + var height = pageReader.GetPageHeight(); + Console.WriteLine("Loading pixel data for page " + pgNum); + using var img = Image.LoadPixelData(rawBytes, width, height); + // you are likely going to want this as well otherwise you might end up with transparent parts. + img.Mutate(x => x.BackgroundColor(SixLabors.ImageSharp.Color.White)); + var pdfPageImageOutputPath = Path.Combine(convertedDir, info.Name + "-Page-" + + (pgNum + 1).ToString().PadLeft(3, '0') + ".jpg"); + img.Save(pdfPageImageOutputPath); + Console.WriteLine("Done rendering pg " + pgNum); + return pdfPageImageOutputPath; + } + // render all pages to images + var docReader = DocLib.Instance.GetDocReader( + filePath, + new PageDimensions(1080, 1920)); // TODO: are these dims right? + // add to document + var pgCount = docReader.GetPageCount(); + if (pgCount > 0) + { + var convertedPdfImagePath = RenderPdfPageToImage(docReader, 0); + imageTitlePar.AddText(string.Format(" (PDF with {0} page{1}) ", + pgCount, + pgCount == 1 ? "" : "s")); + var paragraph = section.AddParagraph(); + paragraph.Format.Alignment = ParagraphAlignment.Center; + var image = paragraph.AddImage(convertedPdfImagePath); + image.Width = imageWidth; + image.LockAspectRatio = true; + for (var j = 1; j < pgCount; j++) + { + section.AddPageBreak(); + paragraph = section.AddParagraph(); + paragraph.Format.Alignment = ParagraphAlignment.Center; + convertedPdfImagePath = RenderPdfPageToImage(docReader, j); + image = paragraph.AddImage(convertedPdfImagePath); + image.LockAspectRatio = true; + image.Width = imageWidth; + } + } + } + else + { + // render first page (eventually need to improve code to just do everything in a loop) + var paragraph = section.AddParagraph(); + paragraph.Format.Alignment = ParagraphAlignment.Center; + var image = paragraph.AddImage(filePath); + image.LockAspectRatio = true; + image.Width = imageWidth; // can't be too wide now...not sure why...maybe due to margins... + // render other PDF pages, if any + // see: https://stackoverflow.com/a/65091204/3938401 + var pdfFileToAdd = PdfReader.Open(filePath, PdfDocumentOpenMode.Import); + var pgCount = pdfFileToAdd.PageCount; + imageTitlePar.AddText(string.Format(" (PDF with {0} page{1}) ", + pgCount, + pgCount == 1 ? "" : "s")); + for (var j = 2; j <= pgCount; j++) + { + section.AddPageBreak(); + paragraph = section.AddParagraph(); + paragraph.Format.Alignment = ParagraphAlignment.Center; + image = paragraph.AddImage(filePath + "#" + j); + image.LockAspectRatio = true; + image.Width = imageWidth; + } + } + } + LogInfo(string.Format("Added image: {0} ({1})", file.Title, filePath)); + hasAddedData = true; + } + var pdfRenderer = new PdfDocumentRenderer + { + Document = pdfDoc, + WorkingDirectory = folderPath + }; + LogInfo("Rendering document to PDF file..."); + pdfRenderer.RenderDocument(); + string outputPDFFilePath = Path.Join(outputDir, outputFileName); + LogInfo("Saving PDF document to disk..."); + pdfRenderer.PdfDocument.Save(outputPDFFilePath); + LogInfo("Finished saving PDF output to: " + outputPDFFilePath); + await CreateAndSaveReportObjectAfterReportCreation(); + // clean up data dir + Directory.Delete(convertedDir, true); + // show output folder to user + OpenFolderForFileInFileViewer(outputPDFFilePath); + IsCreatingPDF = false; + } + + public async Task CheckIsSafeToShutdown() + { + if (!HasUnsavedWork || string.IsNullOrWhiteSpace(WorkingFolder)) + { + return true; + } + else + { + var result = await DialogHost.Show(new ShutdownCheckViewModel()); + if (result != null && result is ShutdownCheckOptions opt) + { + if (opt == ShutdownCheckOptions.SaveAndShutdown) + { + await SaveInterimReportInfo(); + return true; + } + else if (opt == ShutdownCheckOptions.NoSaveShutdown) + { + return true; + } + else if (opt == ShutdownCheckOptions.CancelShutdown) + { + return false; + } + } + } + return false; + } +} \ No newline at end of file diff --git a/src/MayShow.Shared/ViewModels/MainViewModel.cs b/src/MayShow.Shared/ViewModels/MainViewModel.cs index 861fbb4..994f928 100644 --- a/src/MayShow.Shared/ViewModels/MainViewModel.cs +++ b/src/MayShow.Shared/ViewModels/MainViewModel.cs @@ -1,919 +1,47 @@ -#nullable enable - -using System; -using System.Collections.ObjectModel; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Avalonia.Platform.Storage; -using Avalonia.Themes.Fluent; -using DialogHostAvalonia; -using ImageMagick; -using MigraDoc.DocumentObjectModel; -using MigraDoc.Rendering; -using PdfSharp.Fonts; -using PdfSharp.Pdf.IO; -using PdfSharp.Snippets.Font; -using MayShow.Helpers; +using MayShow.Helpers; using MayShow.Interfaces; -using MayShow.Models; -using MayShows.Helpers; - -using Docnet.Core.Models; -using Docnet.Core; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; -using System.Reflection.Metadata.Ecma335; -using Docnet.Core.Readers; +using System.Collections.Generic; namespace MayShow.ViewModels; -class MainViewModel : BaseViewModel, IFontResolver, ICanCheckShutdown +class MainViewModel : ChangeNotifier, IChangeViewModel { - private bool _isPerformingInitialLoad; - private string _processDir; - private bool _isCreatingPDF; - private string _programLog; - private string _workingFolder; + BaseViewModel _currentViewModel; + Stack _viewModels; - private string _reportTitle; - private ObservableCollection _reportFiles; - private DateTime? _lastGeneratedTime; - - private Settings _settings; - - private bool _hasUnsavedWork; - - public MainViewModel(IChangeViewModel viewModelChanger) : base(viewModelChanger) + public MainViewModel(ITopLevelGrabber topLevelGrabber) { - _isPerformingInitialLoad = true; - _processDir = Path.GetDirectoryName(Environment.ProcessPath) ?? ""; - Console.WriteLine("Internal storage directory is: {0}", Utilities.GetInternalDataPath()); - _isCreatingPDF = false; - var quotes = Constants.GetQuotes(); - Random random = new Random(); - var quoteIndex = random.Next(0, quotes.Length); - _programLog = "----- MayShow v" + Constants.AppVersion + " ------" + Environment.NewLine; - _programLog += quotes[quoteIndex] + Environment.NewLine; - _programLog += "---------------------------------------" + Environment.NewLine; - _programLog += "Loaded and ready to create report!" + Environment.NewLine; - _programLog += "Please copy and send this Program Log when reporting any issues with the software."; - _workingFolder = ""; - ReportFiles = _reportFiles = new ObservableCollection(); - _reportTitle = ""; - _lastGeneratedTime = null; - _settings = Settings.LoadSettings(); - if (!string.IsNullOrWhiteSpace(_settings.LastUsedPath)) + _viewModels = new Stack(); + var initialViewModel = new CreatePDFReportViewModel(this) { - LogInfo("Loading data at last used path of {0}", _settings.LastUsedPath); - ScanFolder(_settings.LastUsedPath); - } - else - { - LogInfo("Choose a receipt folder to begin..."); - } - HasUnsavedWork = false; - _isPerformingInitialLoad = false; - } - - public string ReportTitle - { - get => _reportTitle; - set - { - _reportTitle = value; - NotifyPropertyChanged(); - NotifyPropertyChanged(nameof(IsTitleBoxVisible)); - NotifyPropertyChanged(nameof(CanAddItem)); - } - } - - public bool IsTitleBoxVisible - { - get => !string.IsNullOrWhiteSpace(WorkingFolder); - } - - public bool CanAddItem - { - get => IsTitleBoxVisible && !IsCreatingPDF; - } - - public bool IsCreatingPDF - { - get => _isCreatingPDF; - set - { - _isCreatingPDF = value; - NotifyPropertyChanged(); - NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); - NotifyPropertyChanged(nameof(HasWorkingFolderAndNotMakingPDF)); - NotifyPropertyChanged(nameof(CanAddItem)); - } - } - - public bool IsCreatePDFButtonEnabled - { - get => !_isCreatingPDF && _reportFiles.Count > 0; - } - - public bool HasWorkingFolder - { - get => !string.IsNullOrWhiteSpace(WorkingFolder) && Directory.Exists(WorkingFolder); - } - - public bool HasWorkingFolderAndNotMakingPDF - { - get => !string.IsNullOrWhiteSpace(WorkingFolder) && Directory.Exists(WorkingFolder) && !_isCreatingPDF; - } - - public string WorkingFolder - { - get => _workingFolder; - set - { - _workingFolder = value; - NotifyPropertyChanged(); - NotifyPropertyChanged(nameof(HasWorkingFolder)); - NotifyPropertyChanged(nameof(HasWorkingFolderAndNotMakingPDF)); - } - } - - public string ProgramLog - { - get => _programLog; - set { _programLog = value; NotifyPropertyChanged(); } - } - - public bool HasUnsavedWork - { - get => _hasUnsavedWork; - set - { - _hasUnsavedWork = value; - NotifyPropertyChanged(); - } - } - - public ObservableCollection ReportFiles - { - get => _reportFiles; - set - { - _reportFiles = value; - NotifyPropertyChanged(); - _reportFiles.CollectionChanged += ( sender, e ) => - { - NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); - HasUnsavedWork = true; - }; - } - } - - private void LogInfo(string message, params object[]? arguments) - { - var timestamp = string.Format("[{0:s}]", DateTime.Now); - Console.WriteLine(timestamp + " " + message, arguments); - ProgramLog += Environment.NewLine + string.Format(message, arguments ?? []); - } - - public async void ChooseFolder() - { - var topLevel = TopLevelGrabber?.GetTopLevel(); - if (topLevel is not null) - { - var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions() - { - Title = "Pick a folder of files...", - AllowMultiple = false, - }); - if (folders.Count == 1) - { - var folder = folders[0]; - LogInfo("Clearing existing list and loading items in folder: " + folder.Path.LocalPath); - ReportFiles.Clear(); - ScanFolder(folder.Path.LocalPath); - _settings.LastUsedPath = folder.Path.LocalPath; - await _settings.SaveSettingsAsync(); - ResortPDFItemsByDate(); - HasUnsavedWork = true; - } - } - } - - private string GetReportSavedDataPath(string folderPath) - { - if (_settings.SaveReportJsonDataInInternalDir) - { - var internalPath = Utilities.GetInternalDataPath(); - if (!_settings.WorkingFolderToInternalFolderName.ContainsKey(folderPath)) - { - var uuid = ""; - var potentialPath = ""; - var isDone = false; - // make sure uuid not already used...just in case...because paranoia... - do - { - uuid = Guid.NewGuid().ToString(); - potentialPath = Path.Combine(internalPath, uuid); - isDone = !Directory.Exists(potentialPath); - } while (!isDone); - // make internal dir -- using dir so we have option to copy data into dir later if needed - // (if we ever implement a more robust report system where we keep all files) - Directory.CreateDirectory(potentialPath); - _settings.WorkingFolderToInternalFolderName[folderPath] = uuid; - _settings.SaveSettingsNotAsync(); // save new key/value pair - } - return Path.Combine( - internalPath, - _settings.WorkingFolderToInternalFolderName[folderPath], - Constants.ReportSavedDataFileName - ); - } - else - { - return Path.Combine(folderPath, Constants.ReportSavedDataFileName); - } - } - - private void ScanFolder(string path) - { - if (Directory.Exists(path)) - { - WorkingFolder = path; - NotifyPropertyChanged(nameof(IsTitleBoxVisible)); - NotifyPropertyChanged(nameof(CanAddItem)); - var reportFilePath = GetReportSavedDataPath(path); - var successfullyLoadedPriorReport = false; - if (File.Exists(reportFilePath)) - { - // load prior report - var json = File.ReadAllText(reportFilePath); - var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); - var report = JsonSerializer.Deserialize(json, jsonContext.PDFReport); - if (report != null && report.Files.Count > 0) - { - Console.WriteLine("Loading prior report data at {0}", reportFilePath); - ReportFiles = new ObservableCollection(report.Files); - ReportTitle = report.Title; - WorkingFolder = report.BaseFolder; - _lastGeneratedTime = report.LastGenerated ?? null; - LogInfo("Reloaded report last saved at {0}", report.LastSaved); - successfullyLoadedPriorReport = true; - } - } - if (!successfullyLoadedPriorReport) - { - // Scan folder for files and display in DataGrid - ReportFiles.Clear(); - ReportTitle = ""; - var filePaths = Directory.GetFiles(WorkingFolder); - foreach (var filePath in filePaths) - { - AddFileBasedOnPath(filePath); - } - ResortPDFItemsByDate(); - HasUnsavedWork = true; - } - } - else - { - LogInfo("Error: The directory {0} does not exist. Please select another folder.", path); - } - NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); - } - - public void ShowAbout() - { - DialogHost.Show(new AboutViewModel()); - } - - public async Task ShowSettings() - { - var updatedSettings = await DialogHost.Show(new SettingsViewModel(_settings, TopLevelGrabber)); - if (updatedSettings != null) - { - _settings = (Settings)updatedSettings; - await _settings.SaveSettingsAsync(); - LogInfo("Saved updated settings!"); - } - } - - public void RemoveFile(object f) => RemoveFileImpl((ReportFile)f); - - public async void RemoveFileImpl(ReportFile file) - { - var result = await DialogHost.Show(new WarningDeleteItemViewModel(file)); - if (result != null && (bool)result) - { - var idx = ReportFiles.IndexOf(file); - if (idx != -1) - { - ReportFiles.RemoveAt(idx); - HasUnsavedWork = true; - } - } - } - - // https://github.com/AvaloniaUI/Avalonia/issues/10075 - public void EditFileProperties(object f) => EditFilePropertiesImpl((ReportFile)f); - - public async void EditFilePropertiesImpl(ReportFile file) - { - var result = await DialogHost.Show(new EditFileViewModel(file, ViewModelChanger)); - if (result != null && result is ReportFile updatedData) - { - file.Title = updatedData.Title; - file.ReceiptDateTime = updatedData.ReceiptDateTime; - file.Notes = updatedData.Notes; - HasUnsavedWork = true; - } - } - - public async void AddItem() - { - var topLevel = TopLevelGrabber?.GetTopLevel(); - if (topLevel is not null) - { - var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions() - { - Title = "Choose image or PDF files...", - AllowMultiple = true, - FileTypeFilter = [ - new FilePickerFileType("All Types") - { - Patterns = Constants.AllowedFileExtensionPatterns, - AppleUniformTypeIdentifiers = [ "public.image", "com.adobe.pdf", "public.heic" ], - MimeTypes = [ "image/*", "application/pdf", "image/heic" ] - }, - FilePickerFileTypes.ImageAll, - new FilePickerFileType("HEIC Images") - { - Patterns = [ "*.heic" ], - AppleUniformTypeIdentifiers = [ "public.heic" ], - MimeTypes = [ "image/heic" ] - }, - FilePickerFileTypes.Pdf, - ], - }); - if (files.Count > 0) - { - foreach (var file in files) - { - var filePath = file.TryGetLocalPath(); - AddFileBasedOnPath(filePath); - } - } - } - } - - private void AddFileBasedOnPath(string? filePath) - { - if (!string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath) && !filePath.EndsWith(".DS_Store")) - { - // make sure extensions are OK - var fileExtensions = Constants.AllowedFileExtensionsNoStar; - var didMatch = false; - foreach (var fileExtension in fileExtensions) - { - if (filePath.ToLower().EndsWith("." + fileExtension.ToLower())) - { - didMatch = true; - break; - } - } - if (!didMatch) - { - if (!filePath.EndsWith(Constants.ReportSavedDataFileName)) - { - LogInfo("File {0} did not match allowed file extension types, so it was not added.", filePath); - } - } - else - { - var date = Utilities.CheckValidDateInString(filePath); - ReportFiles.Add(new ReportFile() - { - Title = Path.GetFileName(filePath), - ReceiptDateTime = date.HasValue ? date.Value.ToDateTime(TimeOnly.MinValue) : File.GetCreationTime(filePath), - Notes = "", - FilePath = filePath, - }); - HasUnsavedWork = true; - } - } - } - - public async void RemoveAllItems() - { - var result = await DialogHost.Show(new ConfirmViewModel("Warning!", "Are you sure you want to remove all items from this report?", "Remove All Items", "Cancel")); - if (result != null && (bool)result) - { - ReportFiles.Clear(); - HasUnsavedWork = true; - NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); - } - } - - public void LocateFile(object f) => LocateFileImpl((ReportFile) f); - public async void LocateFileImpl(ReportFile reportFile) - { - var topLevel = TopLevelGrabber?.GetTopLevel(); - if (topLevel is not null) - { - var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions() - { - Title = "Choose image or PDF file...", - AllowMultiple = false, - FileTypeFilter = [ - new FilePickerFileType("All Types") - { - Patterns = Constants.AllowedFileExtensionPatterns, - AppleUniformTypeIdentifiers = [ "public.image", "com.adobe.pdf", "public.heic" ], - MimeTypes = [ "image/*", "application/pdf", "image/heic" ] - }, - FilePickerFileTypes.ImageAll, - new FilePickerFileType("HEIC Images") - { - Patterns = [ "*.heic" ], - AppleUniformTypeIdentifiers = [ "public.heic" ], - MimeTypes = [ "image/heic" ] - }, - FilePickerFileTypes.Pdf, - ], - }); - if (files.Count > 0) - { - var file = files[0]; - reportFile.FilePath = file.Path.LocalPath; - HasUnsavedWork = true; - } - } - } - - // https://github.com/AvaloniaUI/Avalonia/issues/10075 - public void OpenFile(object f) => OpenFileImpl((ReportFile)f); - public void OpenFileImpl(ReportFile file) - { - var topLevel = TopLevelGrabber?.GetTopLevel(); - if (topLevel is not null) - { - var launcher = topLevel.Launcher; - launcher.LaunchUriAsync(new Uri(file.FilePath)); - } - } - - public void OpenFileLocation(object f) => OpenFileLocationImpl((ReportFile)f); - - private void OpenFileLocationImpl(ReportFile file) - { - OpenFolderForFileInFileViewer(file.FilePath); - } - - private void OpenFolderForFileInFileViewer(string fullPathToFile) - { - var topLevel = TopLevelGrabber?.GetTopLevel(); - var dirName = Path.GetDirectoryName(fullPathToFile); - if (topLevel is not null && dirName != null) - { - var launcher = topLevel.Launcher; - launcher.LaunchUriAsync(new Uri(dirName)); - } - } - - public void ResortPDFItemsByDate() - { - LogInfo("Sorting report files list..."); - ReportFiles = new ObservableCollection(ReportFiles.OrderBy(x => x.ReceiptDateTime)); - HasUnsavedWork = true; - } - - public async void BuildPDF() - { - if (string.IsNullOrWhiteSpace(ReportTitle)) - { - await DialogHost.Show(new WarningViewModel("You must provide a report title!")); - } - else - { - try - { - await Task.Run(() => CreatePDF(WorkingFolder)); - } catch (Exception e) - { - LogInfo("PDF process failed! Reason: " + e.Message); - if (e.StackTrace != null) - { - LogInfo(e.StackTrace); - } - if (e.InnerException != null) - { - LogInfo("Inner exception: " + e.InnerException.Message); - if (e.InnerException.StackTrace != null) - { - LogInfo(e.InnerException.StackTrace); - } - } - LogInfo("Please report this error to a programmer or fix the issue listed above."); - IsCreatingPDF = false; - } - } - } - - public async Task SaveInterimReportInfo() - { - var report = new PDFReport() - { - Title = ReportTitle, - Files = ReportFiles.ToList(), - BaseFolder = WorkingFolder, - LastSaved = DateTime.Now, - LastGenerated = _lastGeneratedTime, + TopLevelGrabber = topLevelGrabber }; - await SavePDFReportDataToDisk(report); + _viewModels.Push(initialViewModel); + _currentViewModel = initialViewModel; } - private async Task SavePDFReportDataToDisk(PDFReport report) + public BaseViewModel CurrentViewModel { - var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); - using var memoryStream = new MemoryStream(); - await JsonSerializer.SerializeAsync(memoryStream, report, jsonContext.PDFReport); - memoryStream.Position = 0; - using var reader = new StreamReader(memoryStream); - var json = await reader.ReadToEndAsync(); - var savePath = GetReportSavedDataPath(WorkingFolder); - await File.WriteAllTextAsync(savePath, json); - LogInfo("Saved report information to {0}", savePath); - HasUnsavedWork = false; + get { return _currentViewModel; } + set { _currentViewModel = value; NotifyPropertyChanged(); } } - private async Task CreateAndSaveReportObjectAfterReportCreation() + #region IChangeViewModel + + public void PushViewModel(BaseViewModel model) { - var report = new PDFReport() + _viewModels.Push(model); + CurrentViewModel = model; + } + + public void PopViewModel() + { + if (_viewModels.Count > 1) { - Title = ReportTitle, - Files = ReportFiles.ToList(), - BaseFolder = WorkingFolder, - LastSaved = DateTime.Now, - LastGenerated = DateTime.Now, - }; - _lastGeneratedTime = DateTime.Now; - await SavePDFReportDataToDisk(report); - } - - public async Task CopyLogToClipboard() - { - var clipboard = TopLevelGrabber?.GetTopLevel().Clipboard; - if (clipboard != null) - { - await clipboard.SetTextAsync(ProgramLog); - LogInfo("Program log has been copied to the clipboard!"); + _viewModels.Pop(); + CurrentViewModel = _viewModels.Peek(); } } - public byte[]? GetFont(string faceName) - { - LogInfo(string.Format("Loading font {0}", faceName)); - if (faceName == "Noto Sans JP") - { - var path = Path.Combine(_processDir, "Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf"); - if (!File.Exists(path)) - { - path = Path.Combine(_processDir, "../Resources/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf"); - } - return File.ReadAllBytes(path); - } - if (faceName == "Noto Sans JP Bold") - { - var path = Path.Combine(_processDir, "Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf"); - if (!File.Exists(path)) - { - path = Path.Combine(_processDir, "../Resources/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf"); - } - return File.ReadAllBytes(path); - } - return null; - } - - public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic) - { - // LogInfo(string.Format("Resolving font name {0}", familyName)); - if (familyName == "Noto Sans JP") - { - if (bold) - { - return new FontResolverInfo(familyName + " Bold"); - } - return new FontResolverInfo(familyName); - } - return null; - } - - // https://forum.pdfsharp.net/viewtopic.php?f=2&t=1025 - private async Task CreatePDF(string folderPath) - { - // TODO: calculate needed width for images based on page width and margins and all that? - // safety checks - var outputDir = _settings.SaveOutputPdfInWorkingDir ? folderPath : _settings.OutputPdfDir; - if (!Directory.Exists(outputDir)) - { - await DialogHost.Show(new WarningViewModel("Output directory not found! Please adjust your application Settings before continuing. Output directory: " + outputDir)); - return; - } - // start making PDF! - IsCreatingPDF = true; - var pdfDoc = new Document(); - var outputFileName = ReportTitle + ".pdf"; - var folderName = new DirectoryInfo(folderPath).Name; - const int imageWidth = 425; - if (folderName.Contains('-')) - { - // see if year/month format - var parts = folderName.Split('-'); - if (parts[0].Length == 4 && - parts[1].Length <= 2 && - int.TryParse(parts[0], out int year) && int.TryParse(parts[1], out int month)) - { - outputFileName = string.Format("{0} {1} Receipts.pdf", - CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(month), - year); - LogInfo("Auto-changed output file name to " + outputFileName); - } - } - var section = pdfDoc.AddSection(); - section.PageSetup.PageFormat = PageFormat.Letter; - section.PageSetup.PageWidth = "8.5in"; - section.PageSetup.PageHeight = "11in"; - section.PageSetup.TopMargin = "0.5in"; - section.PageSetup.RightMargin = "0.5in"; - section.PageSetup.BottomMargin = "0.5in"; - section.PageSetup.LeftMargin = "0.5in"; - // setup footer for page number - var footerPar = new Paragraph(); - footerPar.Format.Alignment = ParagraphAlignment.Center; - footerPar.Format.Font.Size = 10; - footerPar.AddText("--Page "); - footerPar.AddPageField(); - footerPar.AddText(" of "); - footerPar.AddNumPagesField(); - footerPar.AddText("--"); - footerPar.AddLineBreak(); - footerPar.AddText("Report generated on " + DateTime.Now.ToString("f")); - section.Footers.Primary.Add(footerPar); - // add report title - var reportTitlePar = section.AddParagraph(); - reportTitlePar.Format.Alignment = ParagraphAlignment.Center; - reportTitlePar.Format.Font.Size = 16; - reportTitlePar.Format.Font.Bold = true; - reportTitlePar.Format.Font.Name = "Noto Sans JP"; // has english letters in it, too - reportTitlePar.AddText(ReportTitle); - // get converted files directory path and create it if necessary - var convertedDir = Path.Combine(Utilities.GetInternalDataPath(), "converted"); - if (!Directory.Exists(convertedDir)) - { - Directory.CreateDirectory(convertedDir); - } - // - GlobalFontSettings.FontResolver = this; - GlobalFontSettings.FallbackFontResolver = new FailsafeFontResolver(); - var hasAddedData = false; - for (var i = 0; i < ReportFiles.Count; i++) - { - var file = ReportFiles[i]; - var fileName = file.FileName; - var filePath = file.FilePath; - if (!File.Exists(filePath)) - { - LogInfo("ERROR: File \"{0}\" does not exist at path \"{1}\". Please remove it from the report or re-add it using the Add Item button if you still want it to be in this report.", file.Title, file.FilePath); - IsCreatingPDF = false; - return; - } - if (fileName == ".DS_Store" || fileName == outputFileName) - { - continue; - } - if (i > 0 && hasAddedData) - { - section.AddPageBreak(); - } - var imageTitlePar = section.AddParagraph(); - imageTitlePar.Format.Alignment = ParagraphAlignment.Center; - imageTitlePar.Format.Font.Size = 12; - imageTitlePar.Format.Font.Bold = true; - imageTitlePar.Format.Font.Name = "Noto Sans JP"; // has english letters in it, too - imageTitlePar.AddText(string.IsNullOrWhiteSpace(file.Title) ? file.FileName : file.Title); - var receiptDatePar = section.AddParagraph(); - receiptDatePar.Format.Alignment = ParagraphAlignment.Center; - receiptDatePar.Format.Font.Size = 12; - receiptDatePar.Format.Font.Bold = true; - receiptDatePar.Format.Font.Name = "Noto Sans JP"; // has english letters in it, too - receiptDatePar.AddText(file.ReceiptDate.ToString("yyyy-MM-dd")); - if (!string.IsNullOrWhiteSpace(file.Notes)) - { - var imageNotesPar = section.AddParagraph(); - imageNotesPar.Format.Alignment = ParagraphAlignment.Center; - imageNotesPar.Format.Font.Size = 10; - imageNotesPar.Format.Font.Bold = false; - imageNotesPar.Format.Font.Name = "Noto Sans JP"; - imageNotesPar.AddText(file.Notes); - } - section.AddParagraph(); // add empty line for spacing - // now add the image - var lowerName = fileName.ToLower(); - var isPDF = lowerName.EndsWith(".pdf"); - // convert heic, webp, or png to JPEG for size and ease of use - // (and probably compat reasons too, though I haven't tested that...) - var isHEIC = lowerName.EndsWith(".heic"); - var isWebp = lowerName.EndsWith(".webp"); - var isPNG = lowerName.EndsWith(".png"); - var info = new FileInfo(file.FilePath); - uint loadedImageWidth = 0; - uint loadedImageHeight = 0; - if (!isPDF) - { - using var mImage = new MagickImage(info.FullName); - loadedImageWidth = mImage.Width; - loadedImageHeight = mImage.Height; - var convertedOutputPath = Path.Combine(convertedDir, info.Name + ".jpg"); - var didAdjust = false; - LogInfo("Image orientation of {0} is {1}", fileName, mImage.Orientation); - if (mImage.Orientation != OrientationType.TopLeft) - { - LogInfo("Auto-adjusted image orientation of {0}", fileName); - mImage.AutoOrient(); - didAdjust = true; - } - // perform needed image manipulations - if (isHEIC || isWebp || isPNG || (!isPDF && info.Length > _settings.ImageResizeThreshold * 1024 * 1024)) - { - // Save image as jpg - mImage.Quality = 80; - if (mImage.Width >= 400 || mImage.Height >= 400) - { - loadedImageWidth = (uint)Math.Floor(mImage.Width * 0.5); - loadedImageHeight = (uint)Math.Floor(mImage.Height * 0.5); - mImage.Scale(loadedImageWidth, loadedImageHeight); - LogInfo("Image {2} scaled to {0}x{1}", loadedImageWidth, loadedImageHeight, fileName); - } - didAdjust = true; - LogInfo("Converted image {0} to JPEG", fileName); - } - else - { - // load height/width - loadedImageWidth = mImage.Width; - loadedImageHeight = mImage.Height; - } - if (didAdjust) - { - await mImage.WriteAsync(convertedOutputPath); - filePath = convertedOutputPath; - LogInfo(string.Format("Saved adjusted image to JPEG; file path is now {0}", filePath)); - } - // write to PDF - var paragraph = section.AddParagraph(); - paragraph.Format.Alignment = ParagraphAlignment.Center; - var image = paragraph.AddImage(filePath); - image.LockAspectRatio = true; - if (!isPDF && loadedImageHeight > 600) - { - image.Height = 550; // make sure it will fit on one page - } - else - { - image.Width = imageWidth; // can't be too wide now...not sure why...maybe due to margins... - } - } - else - { - // need to render PDF to images - if (_settings.UseDocnetPDFImageRendering) - { - // render using Docnet library (which utilizes pdfium, the chrome renderer) - string RenderPdfPageToImage(IDocReader docReader, int pgNum) - { - Console.WriteLine("Rendering pg " + pgNum); - using var pageReader = docReader.GetPageReader(pgNum); - Console.WriteLine("Getting image for page " + pgNum); - var rawBytes = pageReader.GetImage(RenderFlags.RenderAnnotations); - Console.WriteLine("Getting width & height for page " + pgNum); - var width = pageReader.GetPageWidth(); - var height = pageReader.GetPageHeight(); - Console.WriteLine("Loading pixel data for page " + pgNum); - using var img = Image.LoadPixelData(rawBytes, width, height); - // you are likely going to want this as well otherwise you might end up with transparent parts. - img.Mutate(x => x.BackgroundColor(SixLabors.ImageSharp.Color.White)); - var pdfPageImageOutputPath = Path.Combine(convertedDir, info.Name + "-Page-" - + (pgNum + 1).ToString().PadLeft(3, '0') + ".jpg"); - img.Save(pdfPageImageOutputPath); - Console.WriteLine("Done rendering pg " + pgNum); - return pdfPageImageOutputPath; - } - // render all pages to images - var docReader = DocLib.Instance.GetDocReader( - filePath, - new PageDimensions(1080, 1920)); // TODO: are these dims right? - // add to document - var pgCount = docReader.GetPageCount(); - if (pgCount > 0) - { - var convertedPdfImagePath = RenderPdfPageToImage(docReader, 0); - imageTitlePar.AddText(string.Format(" (PDF with {0} page{1}) ", - pgCount, - pgCount == 1 ? "" : "s")); - var paragraph = section.AddParagraph(); - paragraph.Format.Alignment = ParagraphAlignment.Center; - var image = paragraph.AddImage(convertedPdfImagePath); - image.Width = imageWidth; - image.LockAspectRatio = true; - for (var j = 1; j < pgCount; j++) - { - section.AddPageBreak(); - paragraph = section.AddParagraph(); - paragraph.Format.Alignment = ParagraphAlignment.Center; - convertedPdfImagePath = RenderPdfPageToImage(docReader, j); - image = paragraph.AddImage(convertedPdfImagePath); - image.LockAspectRatio = true; - image.Width = imageWidth; - } - } - } - else - { - // render first page (eventually need to improve code to just do everything in a loop) - var paragraph = section.AddParagraph(); - paragraph.Format.Alignment = ParagraphAlignment.Center; - var image = paragraph.AddImage(filePath); - image.LockAspectRatio = true; - image.Width = imageWidth; // can't be too wide now...not sure why...maybe due to margins... - // render other PDF pages, if any - // see: https://stackoverflow.com/a/65091204/3938401 - var pdfFileToAdd = PdfReader.Open(filePath, PdfDocumentOpenMode.Import); - var pgCount = pdfFileToAdd.PageCount; - imageTitlePar.AddText(string.Format(" (PDF with {0} page{1}) ", - pgCount, - pgCount == 1 ? "" : "s")); - for (var j = 2; j <= pgCount; j++) - { - section.AddPageBreak(); - paragraph = section.AddParagraph(); - paragraph.Format.Alignment = ParagraphAlignment.Center; - image = paragraph.AddImage(filePath + "#" + j); - image.LockAspectRatio = true; - image.Width = imageWidth; - } - } - } - LogInfo(string.Format("Added image: {0} ({1})", file.Title, filePath)); - hasAddedData = true; - } - var pdfRenderer = new PdfDocumentRenderer - { - Document = pdfDoc, - WorkingDirectory = folderPath - }; - LogInfo("Rendering document to PDF file..."); - pdfRenderer.RenderDocument(); - string outputPDFFilePath = Path.Join(outputDir, outputFileName); - LogInfo("Saving PDF document to disk..."); - pdfRenderer.PdfDocument.Save(outputPDFFilePath); - LogInfo("Finished saving PDF output to: " + outputPDFFilePath); - await CreateAndSaveReportObjectAfterReportCreation(); - // clean up data dir - Directory.Delete(convertedDir, true); - // show output folder to user - OpenFolderForFileInFileViewer(outputPDFFilePath); - IsCreatingPDF = false; - } - - public async Task CheckIsSafeToShutdown() - { - if (!HasUnsavedWork || string.IsNullOrWhiteSpace(WorkingFolder)) - { - return true; - } - else - { - var result = await DialogHost.Show(new ShutdownCheckViewModel()); - if (result != null && result is ShutdownCheckOptions opt) - { - if (opt == ShutdownCheckOptions.SaveAndShutdown) - { - await SaveInterimReportInfo(); - return true; - } - else if (opt == ShutdownCheckOptions.NoSaveShutdown) - { - return true; - } - else if (opt == ShutdownCheckOptions.CancelShutdown) - { - return false; - } - } - } - return false; - } -} \ No newline at end of file + #endregion +} diff --git a/src/MayShow.Shared/ViewModels/MainWindowViewModel.cs b/src/MayShow.Shared/ViewModels/MainWindowViewModel.cs deleted file mode 100644 index 5885e41..0000000 --- a/src/MayShow.Shared/ViewModels/MainWindowViewModel.cs +++ /dev/null @@ -1,49 +0,0 @@ -using MayShow.Helpers; -using MayShow.Interfaces; -using System; -using System.Collections.Generic; -using System.Text; - -namespace MayShow.ViewModels; - -class MainWindowViewModel : ChangeNotifier, IChangeViewModel -{ - BaseViewModel _currentViewModel; - Stack _viewModels; - - public MainWindowViewModel(ITopLevelGrabber topLevelGrabber) - { - _viewModels = new Stack(); - var initialViewModel = new MainViewModel(this) - { - TopLevelGrabber = topLevelGrabber - }; - _viewModels.Push(initialViewModel); - _currentViewModel = initialViewModel; - } - - public BaseViewModel CurrentViewModel - { - get { return _currentViewModel; } - set { _currentViewModel = value; NotifyPropertyChanged(); } - } - - #region IChangeViewModel - - public void PushViewModel(BaseViewModel model) - { - _viewModels.Push(model); - CurrentViewModel = model; - } - - public void PopViewModel() - { - if (_viewModels.Count > 1) - { - _viewModels.Pop(); - CurrentViewModel = _viewModels.Peek(); - } - } - - #endregion -} diff --git a/src/MayShow.Shared/Views/CreatePDFReportView.axaml b/src/MayShow.Shared/Views/CreatePDFReportView.axaml new file mode 100644 index 0000000..46117b5 --- /dev/null +++ b/src/MayShow.Shared/Views/CreatePDFReportView.axaml @@ -0,0 +1,295 @@ + + + + + + + + + + + + + + + : + + + : + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MayShow.Shared/Views/CreatePDFReportView.axaml.cs b/src/MayShow.Shared/Views/CreatePDFReportView.axaml.cs new file mode 100644 index 0000000..0d7ba29 --- /dev/null +++ b/src/MayShow.Shared/Views/CreatePDFReportView.axaml.cs @@ -0,0 +1,44 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using MayShow.ViewModels; + +namespace MayShow.Views; + +public partial class CreatePDFReportView : UserControl +{ + public CreatePDFReportView() + { + this.InitializeComponent(); + LogBlock.PropertyChanged += LogBlock_PropertyChanged; + FilesGrid.CellEditEnded += FileCellEditEnded; + } + + private void LogBlock_PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property.ToString() == "Text") + { + LogScrollView.ScrollToEnd(); + } + } + + public void UnfocusTextbox() + { + var topLevel = TopLevel.GetTopLevel(this); + topLevel?.FocusManager?.ClearFocus(); + if (DataContext is CreatePDFReportViewModel mvm) + { + mvm?.HasUnsavedWork = true; + } + } + + private void FileCellEditEnded(object? sender, DataGridCellEditEndedEventArgs args) + { + if (args.EditAction == DataGridEditAction.Commit && DataContext is CreatePDFReportViewModel mvm) + { + mvm?.HasUnsavedWork = true; + } + } +} diff --git a/src/MayShow.Shared/Views/MainView.axaml b/src/MayShow.Shared/Views/MainView.axaml index 019fa98..7dadd06 100644 --- a/src/MayShow.Shared/Views/MainView.axaml +++ b/src/MayShow.Shared/Views/MainView.axaml @@ -1,295 +1,19 @@ - - - - - - - - - - - - - - - : - - - : - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + diff --git a/src/MayShow.Shared/Views/MainView.axaml.cs b/src/MayShow.Shared/Views/MainView.axaml.cs index 8b5e590..37fc41a 100644 --- a/src/MayShow.Shared/Views/MainView.axaml.cs +++ b/src/MayShow.Shared/Views/MainView.axaml.cs @@ -1,44 +1,11 @@ -using System; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Markup.Xaml; -using MayShow.ViewModels; - -namespace MayShow.Views; - -public partial class MainView : UserControl -{ - public MainView() - { - this.InitializeComponent(); - LogBlock.PropertyChanged += LogBlock_PropertyChanged; - FilesGrid.CellEditEnded += FileCellEditEnded; - } - - private void LogBlock_PropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) - { - if (e.Property.ToString() == "Text") - { - LogScrollView.ScrollToEnd(); - } - } - - public void UnfocusTextbox() - { - var topLevel = TopLevel.GetTopLevel(this); - topLevel?.FocusManager?.ClearFocus(); - if (DataContext is MainViewModel mvm) - { - mvm?.HasUnsavedWork = true; - } - } - - private void FileCellEditEnded(object? sender, DataGridCellEditEndedEventArgs args) - { - if (args.EditAction == DataGridEditAction.Commit && DataContext is MainViewModel mvm) - { - mvm?.HasUnsavedWork = true; - } - } -} +using Avalonia.Controls; + +namespace MayShow.Views; + +public partial class MainView : UserControl +{ + public MainView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/MayShow.iOS/MayShow.iOS.csproj b/src/MayShow.iOS/MayShow.iOS.csproj index 4e9cf6a..416a4a3 100644 --- a/src/MayShow.iOS/MayShow.iOS.csproj +++ b/src/MayShow.iOS/MayShow.iOS.csproj @@ -7,10 +7,10 @@ - + - + diff --git a/src/MayShow.slnx b/src/MayShow.slnx index 5bffe03..c3af9e2 100644 --- a/src/MayShow.slnx +++ b/src/MayShow.slnx @@ -1,4 +1,5 @@ +