diff --git a/TODO.txt b/TODO.txt index 95b9f72..9eaa4c3 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,3 +1,58 @@ +----iOS---- + +-quickstart for loading last edited report on main menu +-duplicate existing report with new name +-cleanup empty uuid folders in case user gets an internal folder created but never saves + +*-always save report data (file locations, title, etc.) to internal dir (might already be done?) + -no more option to save data internally -> ALWAYS save report_data.json internally +*-update project title -> should update recently used data + -this sort of works, something is wrong with the upgrade process where the UUID is not brought over properly; need to test and fix (maybe fixed already and my dataset is wrong?) +*-add dropdown to Add Items button to have add items from folder and remove choose working folder option (data always saved internally) + -not going to do this; they can always select multiple items from the current picker +*-Now that the BaseFolder is always internal...update options for PDF output location + -output in internal dir (default) + -always ask me every time (opens save file picker after generating PDF to ask user where they want it) + -always put in X folder (uses existing option) +~-add option to backup added files to internal data directory (always on for iOS) + *-add option + -add to settings view + -add logic to CreatePDFReportViewModel + -programmed in but not tested yet +-make backup of last generated PDF somewhere (another internal folder, most likely); + -save to PDF report data somewhere for safe-keeping + -allow loading by user +-iOS-specific (MAUI essentials?) + -maui community essentials https://learn.microsoft.com/en-us/dotnet/communitytoolkit/maui/get-started?tabs=CommunityToolkitMaui + -I don't think this is what we are looking for ultimately....? + -https://github.com/AvaloniaUI/Avalonia.Essentials + -https://github.com/AvaloniaUI/AvaloniaMauiHybrid -- this may have to be the way to go or perhaps grab code from https://github.com/AvaloniaUI/Avalonia.Controls.Maui/tree/main/src/Avalonia.Controls.Maui.Essentials because we don't need the avalonia tie in stuff if it "just works"...but don't know if it will "just work" yet. apparently native APIs are available through net10.0-ios or whatnot but I'd rather use the existing wrappers/tutorials since those will just work just like we want... + -auto save on every action + -Take picture + -https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/device-media/picker?view=net-maui-10.0&tabs=macios + -Add pic from gallery + -see above link + -Add file (uses Files picking, I think this already mostly works just need to copy to internal data dir) + -Generate PDF -> Share/print screen to share/print + -https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/data/share?view=net-maui-10.0&tabs=macios + -Make sure clipboard works (not sure if it will out of the box or not) + -https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/data/clipboard?view=net-maui-10.0 + -show report file size in recent list + -make sure setting for backup files is not available (always backs up files on iOS) + +----------- + +https://stackoverflow.com/questions/78855900/error-ios-projects-must-build-with-publishtrimmed-true-when-trimming-is-disabl +https://blog.verslu.is/maui/exclude-assemblies-from-trimming/ + +-p:UseParallelGC=true for faster builds? or /p:MtouchUseLlvm=false? or -p:EnableAssemblyILStripping=false? +possible hints for building publish iOS binaries: https://github.com/dotnet/maui/issues/25022#issuecomment-2385334527 + +linker stuff: https://blog.verslu.is/maui/exclude-assemblies-from-trimming/ +PublishFolderType documentation: https://github.com/dotnet/macios/blob/main/dotnet/BundleContents.md + +https://signpath.org/ + magick "2026-01-29 — \$210 — WORK PERMIT.pdf" -background white -alpha background -alpha off -density 288 -resize 25% page-%03d.jpg https://github.com/dlemstra/Magick.NET/blob/main/docs/ConvertPDF.md https://stackoverflow.com/questions/65089839/add-an-external-pdf-page-to-pdfsharp-migradoc diff --git a/organizing-thoughts.txt b/organizing-thoughts.txt new file mode 100644 index 0000000..8778d43 --- /dev/null +++ b/organizing-thoughts.txt @@ -0,0 +1,13 @@ + +Three ways to get started with a report: + +1. Pick a folder on disk + -> No title, empty list, have base folder to work in + -> Generate UUID, make sure it's unique among all reports that we have + -> on first save or add file (if copying to local), only create internal folder if needed (we know UUID is unique so we are OK if the setting changes later) +2. Pick an already-saved report (that is either in local dir or somewhere else on disk) + -> Have title (presumably), have file list already, have UUID already with internal folder if saved locally, etc. everything basically ready to go +3. Start new, empty report with title + -> Have title, empty list + -> Generate UUID, make sure it's unique among all reports that we have + -> on first save or add file (if copying to local), create folder -> save data \ No newline at end of file diff --git a/src/Helpers/Utilities.cs b/src/Helpers/Utilities.cs deleted file mode 100644 index e0c988e..0000000 --- a/src/Helpers/Utilities.cs +++ /dev/null @@ -1,65 +0,0 @@ - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Tmds.DBus.Protocol; - -namespace MayShow.Helpers; - -class Utilities -{ - public static JsonSerializerOptions GetSerializerOptions() - { - var opts = new JsonSerializerOptions - { - WriteIndented = false, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - return opts; - } - - public static DateOnly? CheckValidDateInString(string str) - { - // https://stackoverflow.com/a/14918404/3938401 - // formats = regex format -> DateTime parsing format - var formats = new Dictionary - { - {@"\d{4}-\d{2}-\d{2}", "yyyy-MM-dd"}, - {@"\d{4}.d{2}.d{2}", "yyyy.MM.dd"}, - {@"\d{8}", "yyyyMMdd"} - }; - foreach (var data in formats) - { - var rgx = new Regex(data.Key); - var mat = rgx.Match(str); - if (mat.Success) - { - var dtStr = mat.ToString(); - var didWork = DateTime.TryParseExact(dtStr, [data.Value], CultureInfo.InvariantCulture, - DateTimeStyles.None, out var parsedDateTime); - if (didWork) - { - return DateOnly.FromDateTime(parsedDateTime); - } - } - } - return null; - } - - public static string GetInternalDataPath() - { - var path = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "MayShow" - ); - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - } - return path; - } -} \ No newline at end of file diff --git a/src/MayShow-icon.ico b/src/MayShow.Desktop/MayShow-icon.ico similarity index 100% rename from src/MayShow-icon.ico rename to src/MayShow.Desktop/MayShow-icon.ico diff --git a/src/MayShow.Desktop/MayShow.Desktop.csproj b/src/MayShow.Desktop/MayShow.Desktop.csproj new file mode 100644 index 0000000..734224a --- /dev/null +++ b/src/MayShow.Desktop/MayShow.Desktop.csproj @@ -0,0 +1,26 @@ + + + + WinExe + net10.0 + enable + true + app.manifest + true + true + true + true + true + MayShow.Desktop + 1.4.2 + MayShow-icon.ico + + + + + None + All + + + + diff --git a/src/Program.cs b/src/MayShow.Desktop/Program.cs similarity index 93% rename from src/Program.cs rename to src/MayShow.Desktop/Program.cs index 2bae6ff..96a531b 100644 --- a/src/Program.cs +++ b/src/MayShow.Desktop/Program.cs @@ -3,7 +3,7 @@ using System; namespace MayShow; -class Program +sealed class Program { // Initialization code. Don't use any Avalonia, third-party APIs or any // SynchronizationContext-reliant code before AppMain is called: things aren't initialized diff --git a/src/app.manifest b/src/MayShow.Desktop/app.manifest similarity index 100% rename from src/app.manifest rename to src/MayShow.Desktop/app.manifest diff --git a/src/App.axaml b/src/MayShow.Shared/App.axaml similarity index 94% rename from src/App.axaml rename to src/MayShow.Shared/App.axaml index 562527a..784dfeb 100644 --- a/src/App.axaml +++ b/src/MayShow.Shared/App.axaml @@ -85,8 +85,8 @@ - - + + @@ -109,6 +109,9 @@ + + + diff --git a/src/App.axaml.cs b/src/MayShow.Shared/App.axaml.cs similarity index 54% rename from src/App.axaml.cs rename to src/MayShow.Shared/App.axaml.cs index 2936c5a..e5b46a8 100644 --- a/src/App.axaml.cs +++ b/src/MayShow.Shared/App.axaml.cs @@ -1,31 +1,47 @@ 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 = 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/Assets/Fonts/FontAwesome/Font Awesome 7 Brands-Regular-400.otf b/src/MayShow.Shared/Assets/Fonts/FontAwesome/Font Awesome 7 Brands-Regular-400.otf similarity index 100% rename from src/Assets/Fonts/FontAwesome/Font Awesome 7 Brands-Regular-400.otf rename to src/MayShow.Shared/Assets/Fonts/FontAwesome/Font Awesome 7 Brands-Regular-400.otf diff --git a/src/Assets/Fonts/FontAwesome/Font Awesome 7 Free-Regular-400.otf b/src/MayShow.Shared/Assets/Fonts/FontAwesome/Font Awesome 7 Free-Regular-400.otf similarity index 100% rename from src/Assets/Fonts/FontAwesome/Font Awesome 7 Free-Regular-400.otf rename to src/MayShow.Shared/Assets/Fonts/FontAwesome/Font Awesome 7 Free-Regular-400.otf diff --git a/src/Assets/Fonts/FontAwesome/Font Awesome 7 Free-Solid-900.otf b/src/MayShow.Shared/Assets/Fonts/FontAwesome/Font Awesome 7 Free-Solid-900.otf similarity index 100% rename from src/Assets/Fonts/FontAwesome/Font Awesome 7 Free-Solid-900.otf rename to src/MayShow.Shared/Assets/Fonts/FontAwesome/Font Awesome 7 Free-Solid-900.otf diff --git a/src/Assets/Fonts/FontAwesome/LICENSE.txt b/src/MayShow.Shared/Assets/Fonts/FontAwesome/LICENSE.txt similarity index 100% rename from src/Assets/Fonts/FontAwesome/LICENSE.txt rename to src/MayShow.Shared/Assets/Fonts/FontAwesome/LICENSE.txt diff --git a/src/Assets/Fonts/Noto_Sans/OFL.txt b/src/MayShow.Shared/Assets/Fonts/Noto_Sans/OFL.txt similarity index 100% rename from src/Assets/Fonts/Noto_Sans/OFL.txt rename to src/MayShow.Shared/Assets/Fonts/Noto_Sans/OFL.txt diff --git a/src/Assets/Fonts/Noto_Sans/README.txt b/src/MayShow.Shared/Assets/Fonts/Noto_Sans/README.txt similarity index 100% rename from src/Assets/Fonts/Noto_Sans/README.txt rename to src/MayShow.Shared/Assets/Fonts/Noto_Sans/README.txt diff --git a/src/Assets/Fonts/Noto_Sans/static/NotoSans-Bold.ttf b/src/MayShow.Shared/Assets/Fonts/Noto_Sans/static/NotoSans-Bold.ttf similarity index 100% rename from src/Assets/Fonts/Noto_Sans/static/NotoSans-Bold.ttf rename to src/MayShow.Shared/Assets/Fonts/Noto_Sans/static/NotoSans-Bold.ttf diff --git a/src/Assets/Fonts/Noto_Sans/static/NotoSans-Italic.ttf b/src/MayShow.Shared/Assets/Fonts/Noto_Sans/static/NotoSans-Italic.ttf similarity index 100% rename from src/Assets/Fonts/Noto_Sans/static/NotoSans-Italic.ttf rename to src/MayShow.Shared/Assets/Fonts/Noto_Sans/static/NotoSans-Italic.ttf diff --git a/src/Assets/Fonts/Noto_Sans/static/NotoSans-Regular.ttf b/src/MayShow.Shared/Assets/Fonts/Noto_Sans/static/NotoSans-Regular.ttf similarity index 100% rename from src/Assets/Fonts/Noto_Sans/static/NotoSans-Regular.ttf rename to src/MayShow.Shared/Assets/Fonts/Noto_Sans/static/NotoSans-Regular.ttf diff --git a/src/Assets/Fonts/Noto_Sans_JP/OFL.txt b/src/MayShow.Shared/Assets/Fonts/Noto_Sans_JP/OFL.txt similarity index 100% rename from src/Assets/Fonts/Noto_Sans_JP/OFL.txt rename to src/MayShow.Shared/Assets/Fonts/Noto_Sans_JP/OFL.txt diff --git a/src/Assets/Fonts/Noto_Sans_JP/README.txt b/src/MayShow.Shared/Assets/Fonts/Noto_Sans_JP/README.txt similarity index 100% rename from src/Assets/Fonts/Noto_Sans_JP/README.txt rename to src/MayShow.Shared/Assets/Fonts/Noto_Sans_JP/README.txt diff --git a/src/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf b/src/MayShow.Shared/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf similarity index 100% rename from src/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf rename to src/MayShow.Shared/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf diff --git a/src/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf b/src/MayShow.Shared/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf similarity index 100% rename from src/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf rename to src/MayShow.Shared/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf diff --git a/src/Assets/LICENSES.txt b/src/MayShow.Shared/Assets/LICENSES.txt similarity index 100% rename from src/Assets/LICENSES.txt rename to src/MayShow.Shared/Assets/LICENSES.txt diff --git a/src/MayShow.Shared/Assets/MayShowIcon.png b/src/MayShow.Shared/Assets/MayShowIcon.png new file mode 100644 index 0000000..33fca3f Binary files /dev/null and b/src/MayShow.Shared/Assets/MayShowIcon.png differ diff --git a/src/MayShow.Shared/Enums/PDFSaveLocation.cs b/src/MayShow.Shared/Enums/PDFSaveLocation.cs new file mode 100644 index 0000000..9185798 --- /dev/null +++ b/src/MayShow.Shared/Enums/PDFSaveLocation.cs @@ -0,0 +1,8 @@ +namespace MayShow.Enums; + +enum PDFSaveLocation : ushort +{ + BaseFolder = 0, + AlwaysAsk = 1, + OtherChosenDir = 2, +} \ No newline at end of file diff --git a/src/Helpers/ChangeNotifier.cs b/src/MayShow.Shared/Helpers/ChangeNotifier.cs similarity index 100% rename from src/Helpers/ChangeNotifier.cs rename to src/MayShow.Shared/Helpers/ChangeNotifier.cs diff --git a/src/Helpers/Constants.cs b/src/MayShow.Shared/Helpers/Constants.cs similarity index 95% rename from src/Helpers/Constants.cs rename to src/MayShow.Shared/Helpers/Constants.cs index 6f4c715..b5a23af 100644 --- a/src/Helpers/Constants.cs +++ b/src/MayShow.Shared/Helpers/Constants.cs @@ -11,6 +11,9 @@ class Constants public static string[] AllowedFileExtensionPatterns = [ "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp", "*.webp", "*.pdf", "*.heic", ]; public static string[] AllowedFileExtensionsNoStar = [ "png", "jpg", "jpeg", "gif", "bmp", "webp", "pdf", "heic", ]; + public static string[] FilePickerMimeTypes = [ "image/*", "application/pdf", "image/heic" ]; + public static string[] FilePickerAppleTypeIdentifiers = [ "public.image", "com.adobe.pdf", "public.heic" ]; + public static string ReportSavedDataFileName = "report_data.json"; public static List GetDateDisplayFormats() diff --git a/src/Helpers/DataGridDropHandler.cs b/src/MayShow.Shared/Helpers/DataGridDropHandler.cs similarity index 95% rename from src/Helpers/DataGridDropHandler.cs rename to src/MayShow.Shared/Helpers/DataGridDropHandler.cs index 7c6e426..2f6cbac 100644 --- a/src/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/Helpers/DateFormatConverter.cs b/src/MayShow.Shared/Helpers/DateFormatConverter.cs similarity index 100% rename from src/Helpers/DateFormatConverter.cs rename to src/MayShow.Shared/Helpers/DateFormatConverter.cs diff --git a/src/Helpers/ListExtensions.cs b/src/MayShow.Shared/Helpers/ListExtensions.cs similarity index 100% rename from src/Helpers/ListExtensions.cs rename to src/MayShow.Shared/Helpers/ListExtensions.cs diff --git a/src/MayShow.Shared/Helpers/PDFFontResolver.cs b/src/MayShow.Shared/Helpers/PDFFontResolver.cs new file mode 100644 index 0000000..40cde71 --- /dev/null +++ b/src/MayShow.Shared/Helpers/PDFFontResolver.cs @@ -0,0 +1,82 @@ + +using System.IO; +using MayShow.Interfaces; +using PdfSharp.Fonts; + +namespace MayShow.Helpers; + +class PDFFontResolver : IFontResolver +{ + private string _runningProcessDirectory; + private ILogger? _logger; + + public PDFFontResolver(string runningProcessDirectory, ILogger? logger) + { + _logger = logger; + _runningProcessDirectory = runningProcessDirectory; + } + + public byte[]? GetFont(string faceName) + { + _logger?.LogInfo(string.Format("Loading font {0}", faceName)); + if (faceName == "Noto Sans") + { + var path = Path.Combine(_runningProcessDirectory, "Assets/Fonts/Noto_Sans/static/NotoSans-Regular.ttf"); + if (!File.Exists(path)) + { + path = Path.Combine(_runningProcessDirectory, "../Resources/Assets/Fonts/Noto_Sans/static/NotoSans-Regular.ttf"); + } + return File.ReadAllBytes(path); + } + if (faceName == "Noto Sans Bold") + { + var path = Path.Combine(_runningProcessDirectory, "Assets/Fonts/Noto_Sans/static/NotoSans-Bold.ttf"); + if (!File.Exists(path)) + { + path = Path.Combine(_runningProcessDirectory, "../Resources/Assets/Fonts/Noto_Sans/static/NotoSans-Bold.ttf"); + } + return File.ReadAllBytes(path); + } + if (faceName == "Noto Sans JP") + { + var path = Path.Combine(_runningProcessDirectory, "Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf"); + if (!File.Exists(path)) + { + path = Path.Combine(_runningProcessDirectory, "../Resources/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf"); + } + return File.ReadAllBytes(path); + } + if (faceName == "Noto Sans JP Bold") + { + var path = Path.Combine(_runningProcessDirectory, "Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf"); + if (!File.Exists(path)) + { + path = Path.Combine(_runningProcessDirectory, "../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") + { + if (bold) + { + return new FontResolverInfo(familyName + " Bold"); + } + return new FontResolverInfo(familyName); + } + if (familyName == "Noto Sans JP") + { + if (bold) + { + return new FontResolverInfo(familyName + " Bold"); + } + return new FontResolverInfo(familyName); + } + return null; + } +} \ No newline at end of file diff --git a/src/Helpers/SourceGenerationContext.cs b/src/MayShow.Shared/Helpers/SourceGenerationContext.cs similarity index 100% rename from src/Helpers/SourceGenerationContext.cs rename to src/MayShow.Shared/Helpers/SourceGenerationContext.cs diff --git a/src/MayShow.Shared/Helpers/Utilities.cs b/src/MayShow.Shared/Helpers/Utilities.cs new file mode 100644 index 0000000..117cecf --- /dev/null +++ b/src/MayShow.Shared/Helpers/Utilities.cs @@ -0,0 +1,164 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Avalonia.Platform.Storage; +using MayShow.Models; + +namespace MayShow.Helpers; + +class Utilities +{ + public static JsonSerializerOptions GetSerializerOptions() + { + var opts = new JsonSerializerOptions + { + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + return opts; + } + + public static DateOnly? CheckValidDateInString(string str) + { + // https://stackoverflow.com/a/14918404/3938401 + // formats = regex format -> DateTime parsing format + var formats = new Dictionary + { + {@"\d{4}-\d{2}-\d{2}", "yyyy-MM-dd"}, + {@"\d{4}.d{2}.d{2}", "yyyy.MM.dd"}, + {@"\d{8}", "yyyyMMdd"} + }; + foreach (var data in formats) + { + var rgx = new Regex(data.Key); + var mat = rgx.Match(str); + if (mat.Success) + { + var dtStr = mat.ToString(); + var didWork = DateTime.TryParseExact(dtStr, [data.Value], CultureInfo.InvariantCulture, + DateTimeStyles.None, out var parsedDateTime); + if (didWork) + { + return DateOnly.FromDateTime(parsedDateTime); + } + } + } + return null; + } + + public static string GetInternalDataPath() + { + var path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "MayShow" + ); + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + return path; + } + + public static string GetTempConvertedImagesFolderPath() + { + // get converted files directory path and create it if necessary + var convertedDir = Path.Combine(GetInternalDataPath(), "converted"); + if (!Directory.Exists(convertedDir)) + { + Directory.CreateDirectory(convertedDir); + } + return convertedDir; + } + + public static Guid GetUniqueReportGuid(Settings settings) + { + // Guid should be, well, unique already, BUT we are not going to take ANY chances. + var internalPath = GetInternalDataPath(); + Guid guid = Guid.NewGuid(); + var isUnique = false; + while (!isUnique) + { + var strUUID = guid.ToString(); + var didFind = false; + foreach (var existingReport in settings.AllReportInfo) + { + if (existingReport.UUID == strUUID) + { + didFind = true; + break; + } + } + if (Directory.Exists(Path.Combine(internalPath, strUUID))) + { + didFind = true; + } + if (!didFind) + { + isUnique = true; + } + else + { + guid = Guid.NewGuid(); + } + } + return guid; + } + + public static void SaveReportDataSync(PDFReport reportData, string path, JsonTypeInfo? context) + { + if (context == null) + { + var jsonContext = new SourceGenerationContext(GetSerializerOptions()); + context = jsonContext.PDFReport; + } + using var memoryStream = new MemoryStream(); + JsonSerializer.Serialize(memoryStream, reportData, context); + memoryStream.Position = 0; + using var reader = new StreamReader(memoryStream); + var updatedJson = reader.ReadToEnd(); + File.WriteAllText(path, updatedJson); + } + + public static async Task SaveReportDataAsync(PDFReport reportData, string path, JsonTypeInfo? context = null) + { + if (context == null) + { + var jsonContext = new SourceGenerationContext(GetSerializerOptions()); + context = jsonContext.PDFReport; + } + using var memoryStream = new MemoryStream(); + await JsonSerializer.SerializeAsync(memoryStream, reportData, context); + memoryStream.Position = 0; + using var reader = new StreamReader(memoryStream); + var json = await reader.ReadToEndAsync(); + await File.WriteAllTextAsync(path, json); + } + + public static FilePickerFileType[] GetReportFilePickerFileTypes() + { + return [ + new FilePickerFileType("All Types") + { + Patterns = Constants.AllowedFileExtensionPatterns, + AppleUniformTypeIdentifiers = Constants.FilePickerAppleTypeIdentifiers, + MimeTypes = Constants.FilePickerMimeTypes, + }, + FilePickerFileTypes.ImageAll, + new FilePickerFileType("HEIC Images") + { + Patterns = [ "*.heic" ], + AppleUniformTypeIdentifiers = [ "public.heic" ], + MimeTypes = [ "image/heic" ] + }, + FilePickerFileTypes.Pdf, + ]; + } +} \ No newline at end of file diff --git a/src/Interfaces/ICanCheckShutdown.cs b/src/MayShow.Shared/Interfaces/ICanCheckShutdown.cs similarity index 100% rename from src/Interfaces/ICanCheckShutdown.cs rename to src/MayShow.Shared/Interfaces/ICanCheckShutdown.cs diff --git a/src/Interfaces/IChangeViewModel.cs b/src/MayShow.Shared/Interfaces/IChangeViewModel.cs similarity index 100% rename from src/Interfaces/IChangeViewModel.cs rename to src/MayShow.Shared/Interfaces/IChangeViewModel.cs diff --git a/src/MayShow.Shared/Interfaces/ILogger.cs b/src/MayShow.Shared/Interfaces/ILogger.cs new file mode 100644 index 0000000..595a557 --- /dev/null +++ b/src/MayShow.Shared/Interfaces/ILogger.cs @@ -0,0 +1,6 @@ +namespace MayShow.Interfaces; + +interface ILogger +{ + void LogInfo(string message, params object[]? arguments); +} \ No newline at end of file diff --git a/src/Interfaces/ITopLevelGrabber.cs b/src/MayShow.Shared/Interfaces/ITopLevelGrabber.cs similarity index 100% rename from src/Interfaces/ITopLevelGrabber.cs rename to src/MayShow.Shared/Interfaces/ITopLevelGrabber.cs diff --git a/src/MayShow.Shared/Interfaces/IUpdateRecentlyUsed.cs b/src/MayShow.Shared/Interfaces/IUpdateRecentlyUsed.cs new file mode 100644 index 0000000..e6103f3 --- /dev/null +++ b/src/MayShow.Shared/Interfaces/IUpdateRecentlyUsed.cs @@ -0,0 +1,8 @@ +using MayShow.Models; + +namespace MayShow.Interfaces; + +interface IUpdateRecentlyUsed +{ + void UpdateRecentlyUsed(PDFReport report); +} \ No newline at end of file diff --git a/src/MainWindow.axaml b/src/MayShow.Shared/MainWindow.axaml similarity index 53% rename from src/MainWindow.axaml rename to src/MayShow.Shared/MainWindow.axaml index 3867d1d..75f6e5c 100644 --- a/src/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/MainWindow.axaml.cs b/src/MayShow.Shared/MainWindow.axaml.cs similarity index 87% rename from src/MainWindow.axaml.cs rename to src/MayShow.Shared/MainWindow.axaml.cs index 33f39fc..3b289eb 100644 --- a/src/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-icon.ico b/src/MayShow.Shared/MayShow-icon.ico new file mode 100644 index 0000000..460ba04 Binary files /dev/null and b/src/MayShow.Shared/MayShow-icon.ico differ diff --git a/src/MayShow.csproj b/src/MayShow.Shared/MayShow.Shared.csproj similarity index 87% rename from src/MayShow.csproj rename to src/MayShow.Shared/MayShow.Shared.csproj index de2dfc4..5687006 100644 --- a/src/MayShow.csproj +++ b/src/MayShow.Shared/MayShow.Shared.csproj @@ -1,70 +1,71 @@ - - - - WinExe - net10.0 - enable - true - app.manifest - true - true - true - true - true - MayShow - 1.4.3 - MayShow-icon.ico - - - - - - - - - - - - - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - - - - - - - - None - All - - - - - - - - - - - - + + + + net10.0 + enable + true + true + MayShow + 1.4.3 + MayShow-icon.ico + + + + + + + + + + + + + + + Always + Resource + + + Always + Resource + + + Always + Resource + + + Always + Resource + + + Always + Resource + + + Always + Resource + + + + + + + + + + None + All + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Models/DateDisplayFormat.cs b/src/MayShow.Shared/Models/DateDisplayFormat.cs similarity index 100% rename from src/Models/DateDisplayFormat.cs rename to src/MayShow.Shared/Models/DateDisplayFormat.cs diff --git a/src/MayShow.Shared/Models/PDFReport.cs b/src/MayShow.Shared/Models/PDFReport.cs new file mode 100644 index 0000000..b218ee9 --- /dev/null +++ b/src/MayShow.Shared/Models/PDFReport.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.ObjectModel; + +namespace MayShow.Models; + +class PDFReport : PDFReportInfo +{ + private ObservableCollection _files; + private DateTime? _lastGenerated; + + public PDFReport() : base() + { + _files = []; + _lastGenerated = null; + } + + public PDFReport(PDFReportInfo info) : base() + { + _files = []; + _lastGenerated = null; + BaseFolder = info.BaseFolder; + UUID = info.UUID; + Title = info.Title; + LastSaved = info.LastSaved; + } + + public ObservableCollection Files + { + get => _files; + set { _files = value; NotifyPropertyChanged(); } + } + + public DateTime? LastGenerated + { + get => _lastGenerated; + set { _lastGenerated = value; NotifyPropertyChanged(); } + } +} \ No newline at end of file diff --git a/src/MayShow.Shared/Models/PDFReportInfo.cs b/src/MayShow.Shared/Models/PDFReportInfo.cs new file mode 100644 index 0000000..ed0ca87 --- /dev/null +++ b/src/MayShow.Shared/Models/PDFReportInfo.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using MayShow.Helpers; + +namespace MayShow.Models; + +class PDFReportInfo : ChangeNotifier +{ + private string _baseFolder; + private string _uuid; + private string _title; + private DateTime? _lastSaved; + + public PDFReportInfo() : base() + { + _uuid = Guid.NewGuid().ToString(); + _baseFolder = ""; + UpdateBaseFolder(); + _title = ""; + _lastSaved = null; + } + + public string BaseFolder + { + get => _baseFolder; + set { _baseFolder = value; NotifyPropertyChanged(); } + } + + public string UUID + { + get => _uuid; + set { _uuid = value; NotifyPropertyChanged(); } + } + + public string Title + { + get => _title; + set { _title = value; NotifyPropertyChanged(); } + } + + public DateTime? LastSaved + { + get => _lastSaved; + set { _lastSaved = value; NotifyPropertyChanged(); } + } + + public void UpdateBaseFolder() + { + _baseFolder = Path.Combine(Utilities.GetInternalDataPath(), _uuid); + } + + public void ResetUUID() + { + UUID = Guid.NewGuid().ToString(); + } + + public string GetWorkingPath() + { + if (string.IsNullOrWhiteSpace(BaseFolder)) + { + return Path.Combine(Utilities.GetInternalDataPath(), UUID); + } + return BaseFolder; + } + + public void DeleteInternalFolderFromDisk() + { + var path = Path.Combine(Utilities.GetInternalDataPath(), UUID); + if (Directory.Exists(path) && path != Utilities.GetInternalDataPath()) + { + Directory.Delete(path, true); + } + } +} \ No newline at end of file diff --git a/src/Models/ReportFile.cs b/src/MayShow.Shared/Models/ReportFile.cs similarity index 98% rename from src/Models/ReportFile.cs rename to src/MayShow.Shared/Models/ReportFile.cs index ddc4c19..fd9d350 100644 --- a/src/Models/ReportFile.cs +++ b/src/MayShow.Shared/Models/ReportFile.cs @@ -12,7 +12,7 @@ class ReportFile : ChangeNotifier private string _notes; private string _filePath; - public ReportFile() + public ReportFile() : base() { _title = ""; _receiptDateTime = DateTime.Now; diff --git a/src/MayShow.Shared/Models/ReportPDFCreator.cs b/src/MayShow.Shared/Models/ReportPDFCreator.cs new file mode 100644 index 0000000..d6b71aa --- /dev/null +++ b/src/MayShow.Shared/Models/ReportPDFCreator.cs @@ -0,0 +1,391 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; +using DialogHostAvalonia; +using Docnet.Core; +using Docnet.Core.Models; +using Docnet.Core.Readers; +using ImageMagick; +using MayShow.Helpers; +using MayShow.Interfaces; +using MayShow.ViewModels; +using MigraDoc.DocumentObjectModel; +using MigraDoc.Rendering; +using PdfSharp.Fonts; +using PdfSharp.Pdf.IO; +using PdfSharp.Snippets.Font; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace MayShow.Models; + +class ReportPDFCreator : ChangeNotifier +{ + private ILogger _logger; + + public ReportPDFCreator(ILogger logger) + { + _logger = logger; + } + + private Paragraph GetFooterParagraph() + { + 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")); + footerPar.Tag = "FooterPar"; + footerPar.Format.Font.Name = "Noto Sans"; + return footerPar; + } + + private decimal GetExistingPageItemHeight(PdfDocumentRenderer pdfRenderer, decimal footerParagraphHeight) + { + pdfRenderer.DocumentRenderer.PrepareDocument(); + var currPageCount = pdfRenderer.DocumentRenderer.FormattedDocument?.PageCount; + var heightForExistingItemsOnPage = footerParagraphHeight; + if (currPageCount.HasValue) + { + var renderInfo = pdfRenderer.DocumentRenderer.GetRenderInfoFromPage(currPageCount.Value); + if (renderInfo != null) + { + // Console.WriteLine("Got render info for page: {0}", currPageCount); + foreach (var item in renderInfo) + { + heightForExistingItemsOnPage += (decimal)item.LayoutInfo.ContentArea.Height.Inch; + } + } + } + return heightForExistingItemsOnPage; + } + + private Paragraph MakeParagraph(Section section, string text, bool isBold, int fontSize, string tag, bool isCenter = true) + { + const string defaultFontName = "Noto Sans JP"; + var par = section.AddParagraph(); + par.Format.Alignment = isCenter ? ParagraphAlignment.Center : ParagraphAlignment.Left; + par.Format.Font.Size = fontSize; + par.Format.Font.Bold = isBold; + par.Format.Font.Name = defaultFontName; // has english letters in it, too + par.AddText(text); + par.Tag = tag; + return par; + } + + // https://forum.pdfsharp.net/viewtopic.php?f=2&t=1025 + public async Task CreatePDF(List reportFiles, string reportTitle, string outputFilePathWithName, PDFFontResolver fontResolver, Settings appSettings) + { + // setup globals and consts... + GlobalFontSettings.FontResolver = fontResolver; + GlobalFontSettings.FallbackFontResolver = new FailsafeFontResolver(); + const int maxImageWidth = 425; + const decimal pageWidth = 8.5m; + const decimal pageHeight = 11.0m; + const decimal margin = 0.5m; + const int imageResolution = 72; + const int imageInsertMarginPixels = 30; // we calculate max available; use max - this # for max image size + const decimal reduceImageSizeAmount = 0.95m; + var maxItemPxWidth = ((pageWidth - (2 * margin)) * imageResolution) - imageInsertMarginPixels; + var imageLineFormat = new MigraDoc.DocumentObjectModel.Shapes.LineFormat() + { + Color = Colors.Black, + Width = Unit.FromPoint(2), + };; + // start making PDF! + var convertedDir = Utilities.GetTempConvertedImagesFolderPath(); + // create doc and setup initial section (for page characteristics) + var pdfDoc = new Document(); + var section = pdfDoc.AddSection(); + section.PageSetup.PageFormat = PageFormat.Letter; + section.PageSetup.PageWidth = pageWidth + "in"; + section.PageSetup.PageHeight = pageHeight + "in"; + section.PageSetup.TopMargin = margin + "in"; + section.PageSetup.RightMargin = margin + "in"; + section.PageSetup.BottomMargin = margin + "in"; + section.PageSetup.LeftMargin = margin + "in"; + // setup footer for page number + var footerPar = GetFooterParagraph(); + section.Footers.Primary.Add(footerPar); + // create a quick PDF doc renderer to measure footer paragraph height + var footerParagraphHeight = 0.4m; // estimate + var footerOnlyPdfDoc = new Document(); + var sectionClone = section.Clone(); + footerOnlyPdfDoc.Add(sectionClone); + sectionClone.Add(GetFooterParagraph()); + var footerPdfRenderer = new PdfDocumentRenderer + { + Document = footerOnlyPdfDoc + }; + footerPdfRenderer.DocumentRenderer.PrepareDocument(); + var footerRenderInfo = footerPdfRenderer.DocumentRenderer.GetRenderInfoFromPage(1); + if (footerRenderInfo != null) + { + foreach (var item in footerRenderInfo) + { + if (item.DocumentObject.Tag?.ToString() == "FooterPar") + { + Console.WriteLine("Got footer paragraph height!"); + footerParagraphHeight = (decimal)item.LayoutInfo.ContentArea.Height.Inch; + break; + } + } + } + // continue setting up document + // First page only: add report title + MakeParagraph(section, reportTitle, true, 16, "TitlePar"); + // + var outputFilePathNoName = Path.GetDirectoryName(outputFilePathWithName) ?? Utilities.GetInternalDataPath(); + var outputFileName = Path.GetFileName(outputFilePathWithName); + var pdfRenderer = new PdfDocumentRenderer + { + Document = pdfDoc, + WorkingDirectory = outputFilePathNoName + }; + 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)) + { + _logger?.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); + return null; + } + if (fileName == ".DS_Store" || fileName == outputFileName) + { + continue; + } + if (i > 0 && hasAddedData) + { + section.AddPageBreak(); + } + var imageTitle = string.IsNullOrWhiteSpace(file.Title) ? file.FileName : file.Title; + var imageTitlePar = MakeParagraph(section, imageTitle, true, 12, "ReceiptTitlePar"); + MakeParagraph(section, file.ReceiptDate.ToString(appSettings.ReportDateFormat), true, 12, "ReceiptDatePar"); + if (!string.IsNullOrWhiteSpace(file.Notes)) + { + var imageNotesPar = MakeParagraph(section, file.Notes, false, 10, "ReceiptNotesPar"); + } + var emptyPar = section.AddParagraph(); // add empty line for spacing + emptyPar.Tag = "EmptyParagraph"; + // 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; + // get max pixel height remaining for items on this page + // (For multi-page PDFs, showing page 2 and on will have more height since they have no title, + // but to keep things consistent we will use the same height for all PDF pages.) + // render up to now on this page and get height remaining in inches + var currPageCount = pdfRenderer.DocumentRenderer.FormattedDocument?.PageCount; + var heightForExistingItemsOnPage = GetExistingPageItemHeight(pdfRenderer, footerParagraphHeight); + var remainingHeightInches = pageHeight - (2 * margin) - heightForExistingItemsOnPage; + var remainingHeightPixels = (remainingHeightInches * imageResolution) - imageInsertMarginPixels; + 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; + _logger?.LogInfo("Image orientation of {0} is {1}", fileName, mImage.Orientation); + if (mImage.Orientation != OrientationType.TopLeft) + { + _logger?.LogInfo("Auto-adjusted image orientation of {0}", fileName); + mImage.AutoOrient(); + didAdjust = true; + } + // perform needed image manipulations + if (isHEIC || isWebp || isPNG || (!isPDF && info.Length > appSettings.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); + _logger?.LogInfo("Image {2} scaled to {0}x{1}", loadedImageWidth, loadedImageHeight, fileName); + } + didAdjust = true; + _logger?.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; + _logger?.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.Resolution = imageResolution; // dots per inch + image.Tag = "ReceiptImageTag"; + paragraph.Tag = "ReceiptImageParagraphTag"; + image.LineFormat = imageLineFormat.Clone(); + // resize down until it will fit on the page + while (loadedImageHeight > remainingHeightPixels || loadedImageWidth > maxItemPxWidth) + { + // Console.WriteLine("Image height = {0}, width = {1}; decreasing size by 5% to h={2}, w={3}", loadedImageHeight, loadedImageWidth, (uint)Math.Floor(loadedImageHeight * reduceImageSizeAmount), (uint)Math.Floor(loadedImageWidth * reduceImageSizeAmount)); + // keep reducing size by 5% (little by little) until it fits on the page + // ...might skew ever so slightly but should not be noticable... + loadedImageHeight = (uint)Math.Floor(loadedImageHeight * reduceImageSizeAmount); + loadedImageWidth = (uint)Math.Floor(loadedImageWidth * reduceImageSizeAmount); + } + image.Height = loadedImageHeight; + image.Width = loadedImageWidth; + } + else // isPDF + { + // need to render PDF to images + if (appSettings.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; + // get image height/width off of disk so we can resize down if needed + var image = paragraph.AddImage(convertedPdfImagePath); + image.LockAspectRatio = true; + image.LineFormat = imageLineFormat.Clone(); + using (var firstPdfPageImage = new MagickImage(convertedPdfImagePath)) + { + var pdfPageImageWidth = firstPdfPageImage.Width; + var pdfPageImageHeight = firstPdfPageImage.Height; + // resize down until it will fit on the page + while (pdfPageImageHeight > remainingHeightPixels || pdfPageImageWidth > maxItemPxWidth) + { + pdfPageImageHeight = (uint)Math.Floor(pdfPageImageHeight * reduceImageSizeAmount); + pdfPageImageWidth = (uint)Math.Floor(pdfPageImageWidth * reduceImageSizeAmount); + } + image.Height = pdfPageImageHeight; + image.Width = pdfPageImageWidth; + } + 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 = maxImageWidth; + image.LineFormat = imageLineFormat.Clone(); + using (var otherPdfPageImage = new MagickImage(convertedPdfImagePath)) + { + var pdfPageImageWidth = otherPdfPageImage.Width; + var pdfPageImageHeight = otherPdfPageImage.Height; + // resize down until it will fit on the page + while (pdfPageImageHeight > remainingHeightPixels || pdfPageImageWidth > maxItemPxWidth) + { + pdfPageImageHeight = (uint)Math.Floor(pdfPageImageHeight * reduceImageSizeAmount); + pdfPageImageWidth = (uint)Math.Floor(pdfPageImageWidth * reduceImageSizeAmount); + } + image.Height = pdfPageImageHeight; + image.Width = pdfPageImageWidth; + } + } + } + } + else + { + // use older, not-docnet rendering method. + // uses MigraDoc rendering. Does not work with annotations, and since Migradoc + // doesn't let us know how big the image is, we can't do the image resizing, so + // we just do our best. + // 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 = maxImageWidth; // can't be too wide now...not sure why...maybe due to margins... + image.LineFormat = imageLineFormat.Clone(); + // 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 = maxImageWidth; + image.LineFormat = imageLineFormat.Clone(); + } + } + } + _logger?.LogInfo(string.Format("Added image: {0} ({1})", file.Title, filePath)); + hasAddedData = true; + } + _logger?.LogInfo("Rendering document to PDF file..."); + pdfRenderer.DocumentRenderer.PrepareDocument(); // needed if you make edits after first PrepareDocument() is called + pdfRenderer.RenderDocument(); + // actually save to disk now + _logger?.LogInfo("Saving PDF document to disk..."); + pdfRenderer.PdfDocument.Save(outputFilePathWithName); + _logger?.LogInfo("Finished saving PDF output to: " + outputFilePathWithName); + // clean up converted files data dir + Directory.Delete(convertedDir, true); + // return output path + return outputFilePathWithName; + } +} \ No newline at end of file diff --git a/src/MayShow.Shared/Models/Settings.cs b/src/MayShow.Shared/Models/Settings.cs new file mode 100644 index 0000000..16647c5 --- /dev/null +++ b/src/MayShow.Shared/Models/Settings.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using MayShow.Enums; +using MayShow.Helpers; + +namespace MayShow.Models; + +class Settings : ChangeNotifier +{ + private string _lastUsedPath; + private bool _useDocnetPDFImageRendering; + private bool _saveOutputPdfInWorkingDir; // obsolete + private string _outputPdfDir; + private decimal _imageResizeThreshold; + private Dictionary _workingFolderToInternalFolderName; // obsolete + private List _allReportInfo; + public string _dataGridDateFormat; + public string _reportDateFormat; + public int _settingsVersion; + private PDFSaveLocation _pdfOutputSaveLocation; + public bool _copyFilesToInternalDir; + + public Settings() : base() + { + _lastUsedPath = ""; + _useDocnetPDFImageRendering = true; + _saveOutputPdfInWorkingDir = true; + _outputPdfDir = ""; + _imageResizeThreshold = 1.5m; + _workingFolderToInternalFolderName = []; + _allReportInfo = []; + _settingsVersion = 3; + _dataGridDateFormat = "dd/MM/yyyy"; + _reportDateFormat = "yyyy-MM-dd"; + _pdfOutputSaveLocation = PDFSaveLocation.BaseFolder; + _copyFilesToInternalDir = false; + #if IOS + _copyFilesToInternalDir = true; + #endif + } + + public Settings(Settings other) + { + _lastUsedPath = other.LastUsedPath; + _useDocnetPDFImageRendering = other.UseDocnetPDFImageRendering; + _saveOutputPdfInWorkingDir = other.SaveOutputPdfInWorkingDir; + _outputPdfDir = other.OutputPdfDir; + _imageResizeThreshold = other.ImageResizeThreshold; + _workingFolderToInternalFolderName = other.WorkingFolderToInternalFolderName; + _settingsVersion = other.SettingsVersion; + _allReportInfo = other.AllReportInfo; + _dataGridDateFormat = other.DataGridDateFormat; + _reportDateFormat = other.ReportDateFormat; + _pdfOutputSaveLocation = other.PDFOutputSaveLocation; + _copyFilesToInternalDir = other.CopyFilesToInternalDir; + } + + [JsonInclude] + public string LastUsedPath + { + get => _lastUsedPath; + set { _lastUsedPath = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + [JsonPropertyName("UseDocnetPFDImageRendering")] // ...this typo now has to live because people have this saved on disk... + public bool UseDocnetPDFImageRendering + { + get => _useDocnetPDFImageRendering; + set { _useDocnetPDFImageRendering = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + public bool SaveOutputPdfInWorkingDir + { + get => _saveOutputPdfInWorkingDir; + set { _saveOutputPdfInWorkingDir = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + public PDFSaveLocation PDFOutputSaveLocation + { + get => _pdfOutputSaveLocation; + set { _pdfOutputSaveLocation = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + public string OutputPdfDir + { + get => _outputPdfDir; + set { _outputPdfDir = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + public decimal ImageResizeThreshold + { + get => _imageResizeThreshold; + set { _imageResizeThreshold = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + public Dictionary WorkingFolderToInternalFolderName + { + get => _workingFolderToInternalFolderName; + set { _workingFolderToInternalFolderName = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + public List AllReportInfo + { + get => _allReportInfo; + set { _allReportInfo = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + public int SettingsVersion + { + get => _settingsVersion; + set { _settingsVersion = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + public string DataGridDateFormat + { + get => _dataGridDateFormat; + set { _dataGridDateFormat = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + public string ReportDateFormat + { + get => _reportDateFormat; + set { _reportDateFormat = value; NotifyPropertyChanged(); } + } + + [JsonInclude] + public bool CopyFilesToInternalDir + { + get => _copyFilesToInternalDir; + set { _copyFilesToInternalDir = value; NotifyPropertyChanged(); } + } + + public static string SettingsFileName = "settings.json"; + + public static string GetSettingsPath() + { + var path = Utilities.GetInternalDataPath(); + return Path.Combine(path, SettingsFileName); + } + + + public string SaveSettingsNotAsync() + { + var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); + using MemoryStream memoryStream = new MemoryStream(); + JsonSerializer.Serialize(memoryStream, this, jsonContext.Settings); + memoryStream.Position = 0; + using var reader = new StreamReader(memoryStream); + var json = reader.ReadToEnd(); + File.WriteAllText(GetSettingsPath(), json); + return json; + } + + public async Task SaveSettingsAsync() + { + var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); + using MemoryStream memoryStream = new MemoryStream(); + await JsonSerializer.SerializeAsync(memoryStream, this, jsonContext.Settings); + memoryStream.Position = 0; + using var reader = new StreamReader(memoryStream); + var json = await reader.ReadToEndAsync(); + await File.WriteAllTextAsync(GetSettingsPath(), json); + return json; + } + + private static Settings UpgradeSettings(Settings settings) + { + if (settings.SettingsVersion == 1) + { + // update settings + var internalPath = Utilities.GetInternalDataPath(); + var list = new List(); + foreach (var data in settings.WorkingFolderToInternalFolderName) + { + var uuid = data.Value; + var path = Path.Combine(internalPath, uuid, Constants.ReportSavedDataFileName); + var json = File.ReadAllText(path); + var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); + var report = File.Exists(path) ? JsonSerializer.Deserialize(json, jsonContext.PDFReport) : null; + var reportTitle = report?.Title ?? ""; + var lastSaved = report?.LastSaved; + var reportInfo = new PDFReportInfo() + { + Title = reportTitle, + UUID = uuid, + LastSaved = lastSaved, + BaseFolder = data.Key, + }; + // sync UUIDs + // if UUID exists in BaseFolder/(Constants.ReportSavedDataFileName), use that UUID instead. + var externalReportDataPath = Path.Combine(reportInfo.BaseFolder, Constants.ReportSavedDataFileName); + if (File.Exists(externalReportDataPath)) + { + var originalReportData = JsonSerializer.Deserialize(File.ReadAllText(externalReportDataPath), jsonContext.PDFReport); + if (originalReportData != null) + { + if (!string.IsNullOrWhiteSpace(originalReportData.UUID)) + { + Directory.Move(Path.Combine(internalPath, uuid), Path.Combine(internalPath, originalReportData.UUID)); + reportInfo.UUID = originalReportData.UUID; + } + else + { + // update UUID so they are in sync between internal and external folders + originalReportData.UUID = reportInfo.UUID; + Utilities.SaveReportDataSync(originalReportData, externalReportDataPath, jsonContext.PDFReport); + } + } + } + // update report data itself and move to internal -- everything is moving to internal storage dir, + // so if there is external data, use whatever is the most recent. + // reportInfo.UUID now has the UUID we want to use. + var internalReportFolderPath = Path.Combine(internalPath, reportInfo.UUID); + var internalDataFilePath = Path.Combine(internalReportFolderPath, Constants.ReportSavedDataFileName); + if (!Path.Exists(internalReportFolderPath)) + { + // internal path doesn't exist at all so never saved internally before. + // make the dir and copy data to internal dir. + Directory.CreateDirectory(internalReportFolderPath); + if (File.Exists(externalReportDataPath)) + { + File.Copy(externalReportDataPath, Path.Combine(internalReportFolderPath, Constants.ReportSavedDataFileName)); + } + } + else + { + // see which JSON file is newer (based on last saved time) and use that data. + if (!File.Exists(internalDataFilePath)) + { + // internal file doesn't exist, copy in from external + if (File.Exists(externalReportDataPath)) + { + File.Copy(externalReportDataPath, internalDataFilePath); + } + } + else if (File.Exists(internalDataFilePath) && File.Exists(externalReportDataPath)) + { + // both files exist. load report data and compare dates. + var internalReportData = JsonSerializer.Deserialize(File.ReadAllText(internalDataFilePath), jsonContext.PDFReport); + var externalReportData = JsonSerializer.Deserialize(File.ReadAllText(externalReportDataPath), jsonContext.PDFReport); + if (internalReportData != null && externalReportData != null) + { + var isExternalNewer = (externalReportData.LastSaved ?? DateTime.MinValue) + > (internalReportData.LastSaved ?? DateTime.MinValue); + if (isExternalNewer) // else internal is newer so nothing to do + { + File.Move(internalDataFilePath, Path.Combine(internalReportFolderPath, "old_report_data.json")); + File.Copy(externalReportDataPath, internalDataFilePath, true); + reportInfo.Title = externalReportData.Title; + reportInfo.LastSaved = externalReportData.LastSaved; + } + } + else if (internalReportData == null && externalReportData != null) + { + // move data to internal dir + if (File.Exists(externalReportDataPath)) + { + File.Copy(externalReportDataPath, internalDataFilePath, true); + } + } + } + } + reportInfo.BaseFolder = internalReportFolderPath; + // make sure BaseFolder is set right just in case -- now always points to internal directory. + // (it's actually now redundant because all settings are internal... + // but for now we'll just let it stick around.) + if (File.Exists(internalDataFilePath)) + { + var internalReportData = JsonSerializer.Deserialize(File.ReadAllText(internalDataFilePath), jsonContext.PDFReport); + if (internalReportData != null) + { + internalReportData.BaseFolder = internalReportFolderPath; + Utilities.SaveReportDataSync(internalReportData, internalDataFilePath, jsonContext.PDFReport); + } + } + // ok, finally done upgrading this report. + list.Add(reportInfo); + } + settings.AllReportInfo = list.OrderBy(x => x.Title).ToList(); + settings.WorkingFolderToInternalFolderName = []; // clear this list; it is no longer going to be used + settings.SettingsVersion = 2; + settings.SaveSettingsNotAsync(); // saves all data; UUIDs should be in sync if user has toggled settings + } + if (settings.SettingsVersion == 2) + { + if (!settings.SaveOutputPdfInWorkingDir) + { + settings.PDFOutputSaveLocation = PDFSaveLocation.OtherChosenDir; + } + settings.SettingsVersion = 3; + settings.SaveSettingsNotAsync(); + } + return settings; + } + + public static Settings LoadSettings() + { + var path = GetSettingsPath(); + if (!File.Exists(path)) + { + return new Settings(); + } + var json = File.ReadAllText(GetSettingsPath()); + var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); + return UpgradeSettings(JsonSerializer.Deserialize(json, jsonContext.Settings) ?? new Settings()); + } + + public static async Task LoadSettingsAsync() + { + using FileStream fileStream = File.OpenRead(GetSettingsPath()); + var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); + var output = await JsonSerializer.DeserializeAsync(fileStream, jsonContext.Settings) ?? new Settings(); + return UpgradeSettings(output); + } +} \ No newline at end of file diff --git a/src/Models/ShutdownCheckOptions.cs b/src/MayShow.Shared/Models/ShutdownCheckOptions.cs similarity index 100% rename from src/Models/ShutdownCheckOptions.cs rename to src/MayShow.Shared/Models/ShutdownCheckOptions.cs diff --git a/src/MayShow.Shared/ViewModels/AboutViewModel.cs b/src/MayShow.Shared/ViewModels/AboutViewModel.cs new file mode 100644 index 0000000..68bb542 --- /dev/null +++ b/src/MayShow.Shared/ViewModels/AboutViewModel.cs @@ -0,0 +1,15 @@ +using DialogHostAvalonia; + +namespace MayShow.ViewModels; + +class AboutViewModel +{ + public AboutViewModel() + { + } + + public void Close() + { + DialogHost.Close("DialogHost", null); + } +} \ No newline at end of file diff --git a/src/ViewModels/BaseViewModel.cs b/src/MayShow.Shared/ViewModels/BaseViewModel.cs similarity index 81% rename from src/ViewModels/BaseViewModel.cs rename to src/MayShow.Shared/ViewModels/BaseViewModel.cs index 24daa94..a68e512 100644 --- a/src/ViewModels/BaseViewModel.cs +++ b/src/MayShow.Shared/ViewModels/BaseViewModel.cs @@ -1,9 +1,5 @@ -using Avalonia.Controls; -using MayShow.Helpers; +using MayShow.Helpers; using MayShow.Interfaces; -using System; -using System.Collections.Generic; -using System.Text; namespace MayShow.ViewModels; @@ -12,7 +8,7 @@ class BaseViewModel : ChangeNotifier IChangeViewModel _viewModelChanger; ITopLevelGrabber? _topLevelGrabber; - public BaseViewModel(IChangeViewModel viewModelChanger) + public BaseViewModel(IChangeViewModel viewModelChanger): base() { _viewModelChanger = viewModelChanger; _topLevelGrabber = null; diff --git a/src/ViewModels/ConfirmViewModel.cs b/src/MayShow.Shared/ViewModels/ConfirmViewModel.cs similarity index 51% rename from src/ViewModels/ConfirmViewModel.cs rename to src/MayShow.Shared/ViewModels/ConfirmViewModel.cs index 54b05a4..b77fd58 100644 --- a/src/ViewModels/ConfirmViewModel.cs +++ b/src/MayShow.Shared/ViewModels/ConfirmViewModel.cs @@ -1,16 +1,16 @@ -#nullable enable - using DialogHostAvalonia; using MayShow.Helpers; namespace MayShow.ViewModels; -class ConfirmViewModel +class ConfirmViewModel : ChangeNotifier { private string _title; private string _message; private string _confirmTitle; private string _declineTitle; + private bool _confirmButtonUsesDangerStyle; + private string _confirmButtonIcon; public ConfirmViewModel(string title, string message, string confirmTitle = "Yes", string declineTitle = "No") { @@ -18,6 +18,8 @@ class ConfirmViewModel _message = message; _confirmTitle = confirmTitle; _declineTitle = declineTitle; + _confirmButtonUsesDangerStyle = false; + _confirmButtonIcon = ""; } public string Title @@ -40,6 +42,35 @@ class ConfirmViewModel get => _declineTitle; } + public bool ConfirmButtonIsAccent + { + get => !_confirmButtonUsesDangerStyle; + } + + public bool ConfirmButtonIsDanger + { + get => _confirmButtonUsesDangerStyle; + } + + public bool ConfirmButtonUsesDangerStyle + { + set + { + _confirmButtonUsesDangerStyle = value; + NotifyPropertyChanged(nameof(ConfirmButtonIsAccent)); + NotifyPropertyChanged(nameof(ConfirmButtonIsDanger)); + } + } + + public string ConfirmTitleIcon + { + get => _confirmButtonIcon; + set + { + _confirmButtonIcon = value; NotifyPropertyChanged(); + } + } + public void Confirm() { DialogHost.Close("DialogHost", true); diff --git a/src/MayShow.Shared/ViewModels/CreatePDFReportViewModel.cs b/src/MayShow.Shared/ViewModels/CreatePDFReportViewModel.cs new file mode 100644 index 0000000..9410c6e --- /dev/null +++ b/src/MayShow.Shared/ViewModels/CreatePDFReportViewModel.cs @@ -0,0 +1,649 @@ +#nullable enable + +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Avalonia.Platform.Storage; +using DialogHostAvalonia; +using MayShow.Helpers; +using MayShow.Interfaces; +using MayShow.Models; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Avalonia.Threading; +using MayShow.Enums; + +namespace MayShow.ViewModels; + +class CreatePDFReportViewModel : BaseViewModel, ICanCheckShutdown, ILogger +{ + #pragma warning disable CS0414 + private bool _isPerformingInitialLoad; + #pragma warning restore CS0414 + private string _processDir; + private string _programLog; + private bool _isCreatingPDF; + + private PDFReport _pdfReport; + + private Settings _settings; + private List _dateDisplayFormats; + private bool _hasUnsavedWork; + + private CreatePDFReportViewModel(IChangeViewModel viewModelChanger) : base(viewModelChanger) + { + _pdfReport = new PDFReport(); + _processDir = Path.GetDirectoryName(Environment.ProcessPath) ?? ""; + Console.WriteLine("Internal storage directory is: {0}", Utilities.GetInternalDataPath()); + _isCreatingPDF = false; + ReportFiles = []; + _programLog = ""; + _settings = Settings.LoadSettings(); + _dateDisplayFormats = Constants.GetDateDisplayFormats(); + NotifyPropertyChanged(nameof(DataGridDateFormat)); + NotifyPropertyChanged(nameof(DataGridDateFormatWatermark)); + HasUnsavedWork = false; + // setup initial quote and program log data + InitializeProgramLog(); + } + + public CreatePDFReportViewModel(PDFReportInfo reportInfo, IChangeViewModel viewModelChanger) : this(viewModelChanger) + { + _isPerformingInitialLoad = true; + _pdfReport = new PDFReport(reportInfo); + // always default to using BaseFolder, which will always be set in the general case + if (!string.IsNullOrWhiteSpace(_pdfReport.BaseFolder)) + { + LogInfo("Loading report data at path: {0}", _pdfReport.BaseFolder); + ScanFolder(_pdfReport.BaseFolder); + } + else + { + // load data file in internal data report dir + _pdfReport.BaseFolder = Path.Combine(Utilities.GetInternalDataPath(), _pdfReport.UUID); + if (Directory.Exists(_pdfReport.BaseFolder)) + { + ScanFolder(_pdfReport.BaseFolder); // even if points entirely to internal folder, we will be A-OK loading here + } + else + { + LogInfo("Erorr loading report! Folder does not exist: {0}", _pdfReport.BaseFolder); + } + } + _isPerformingInitialLoad = false; + } + + public IUpdateRecentlyUsed? UpdateRecentlyUsed { get; set; } + + public PDFReport PDFReport + { + get => _pdfReport; + set + { + _pdfReport = value; + NotifyPropertyChanged(nameof(ReportTitle)); + NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); + NotifyPropertyChanged(nameof(ReportFiles)); + SetupFileCollectionChangedWatcher(); + } + } + + public string ReportTitle + { + get => _pdfReport.Title; + set + { + _pdfReport.Title = value; + NotifyPropertyChanged(); + HasUnsavedWork = true; + } + } + + public bool CanAddItem + { + get => !IsCreatingPDF; + } + + public bool IsCreatingPDF + { + get => _isCreatingPDF; + set + { + _isCreatingPDF = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); + NotifyPropertyChanged(nameof(CanAddItem)); + NotifyPropertyChanged(nameof(IsSaveButtonAccentOn)); + } + } + + public bool IsSaveButtonAccentOn + { + get => !_isCreatingPDF && HasUnsavedWork; + } + + public bool IsCreatePDFButtonEnabled + { + get => !_isCreatingPDF && _pdfReport.Files.Count > 0; + } + + public string ProgramLog + { + get => _programLog; + set { _programLog = value; NotifyPropertyChanged(); } + } + + public bool HasUnsavedWork + { + get => _hasUnsavedWork; + set + { + _hasUnsavedWork = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(IsSaveButtonAccentOn)); + } + } + + public ObservableCollection ReportFiles + { + get => _pdfReport.Files; + set + { + _pdfReport.Files = value; + NotifyPropertyChanged(); + SetupFileCollectionChangedWatcher(); + } + } + + public string DataGridDateFormat + { + get => _settings.DataGridDateFormat; + } + + public string DataGridDateFormatWatermark + { + get => _dateDisplayFormats.FirstOrDefault(x => x.Value == _settings.DataGridDateFormat)?.Example ?? "2025-12-04"; + } + + private void SetupFileCollectionChangedWatcher() + { + _pdfReport.Files.CollectionChanged += ( sender, e ) => + { + NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); + HasUnsavedWork = true; + }; + } + + private void InitializeProgramLog() + { + var quotes = Constants.GetQuotes(); + var random = new Random(); + var quoteIndex = random.Next(0, quotes.Length); + var compDetails = RuntimeInformation.OSDescription + " | " + + RuntimeInformation.OSArchitecture.ToString(); + _programLog = "----- MayShow v" + Constants.AppVersion + " | " + compDetails + " ------" + 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."; + } + + public 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() + { + if (!Directory.Exists(_pdfReport.BaseFolder)) + { + Directory.CreateDirectory(_pdfReport.BaseFolder); + } + return Path.Combine(_pdfReport.BaseFolder, Constants.ReportSavedDataFileName); + } + + private void ScanFolder(string path) + { + if (Directory.Exists(path)) + { + var reportFilePath = GetReportSavedDataPath(); + var successfullyLoadedPriorReportFile = false; + if (File.Exists(reportFilePath)) + { + // load prior report + var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); + var report = JsonSerializer.Deserialize(File.ReadAllText(reportFilePath), jsonContext.PDFReport); + if (report != null) + { + PDFReport = report; + Console.WriteLine("Loading prior report data at {0}", reportFilePath); + LogInfo("Reloaded report last saved at {0}", report.LastSaved ?? DateTime.Now); + successfullyLoadedPriorReportFile = true; + } + } + if (!successfullyLoadedPriorReportFile) + { + // Scan folder for files and display in DataGrid + if (path != PDFReport.BaseFolder) + { + // in this case, there is essentially no existing report, + // so we need to make a new one. + PDFReport = new PDFReport() + { + Title = Path.GetDirectoryName(path) ?? "", + LastSaved = null, + UUID = Utilities.GetUniqueReportGuid(_settings).ToString(), + }; + PDFReport.UpdateBaseFolder(); + } + ReportFiles.Clear(); + ReportTitle = ""; + var filePaths = Directory.GetFiles(path); + 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)); + } + + // 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 = Utilities.GetReportFilePickerFileTypes(), + }); + 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); + if (_settings.CopyFilesToInternalDir) + { + // copy file to internal folder, then add report file based on that path + // make sure file names are not conflicting with one another, too. + var fileName = Path.GetFileName(filePath); + var fileNameNoExt = Path.GetFileNameWithoutExtension(filePath); + var extension = Path.GetExtension(filePath); + var copyToPath = Path.Combine(_pdfReport.BaseFolder, fileName); + var rnd = new Random(); + // TODO: test to make sure this works + while (File.Exists(copyToPath)) + { + fileName = fileNameNoExt + rnd.Next(1, 999999) + "." + extension; + copyToPath = Path.Combine(_pdfReport.BaseFolder, fileName); + } + File.Copy(filePath, copyToPath); + filePath = copyToPath; + } + 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 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; + } + } + } + + 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") + { + ConfirmButtonUsesDangerStyle = true, + ConfirmTitleIcon = "\uf1f8;" + }); + 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 = Utilities.GetReportFilePickerFileTypes(), + }); + 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) + .ThenBy(x => x.Title)); + HasUnsavedWork = true; + } + + // called from UI button + public async void BuildPDF() + { + if (string.IsNullOrWhiteSpace(ReportTitle)) + { + await DialogHost.Show(new WarningViewModel("You must provide a report title!")); + } + else + { + try + { + var outputFilePath = await DeterminePDFSaveLocation(); + if (outputFilePath == null && _settings.PDFOutputSaveLocation != PDFSaveLocation.AlwaysAsk) + { + // if always ask and output is null, they probably hit cancel. + await DialogHost.Show(new WarningViewModel("Error: Output file path could not be determined. Current save location set in settings: " + Enum.GetName(_settings.PDFOutputSaveLocation))); + } + else if (_settings.PDFOutputSaveLocation == PDFSaveLocation.OtherChosenDir && + !Directory.Exists(_settings.OutputPdfDir)) + { + await DialogHost.Show(new WarningViewModel("Error: Output directory not found! Please adjust the application Settings before continuing. Output directory: " + _settings.OutputPdfDir)); + } + else if (outputFilePath != null) + { + await Task.Run(() => CreatePDF(outputFilePath)); + } + } catch (Exception e) + { + LogInfo("PDF process failed! Reason: " + e.Message); + if (e.StackTrace != null) + { + LogInfo(e.StackTrace); + } + var otherException = e.InnerException; + while (otherException != null) + { + LogInfo(">> Inner exception: " + otherException.Message); + if (otherException.StackTrace != null) + { + LogInfo(otherException.StackTrace); + } + otherException = otherException.InnerException; + } + LogInfo("Please report this error to a programmer or fix the issue listed above."); + IsCreatingPDF = false; + } + } + } + + public async Task SaveInterimReportInfo() + { + _pdfReport.LastSaved = DateTime.Now; + await SavePDFReportDataToDisk(_pdfReport); + } + + private async Task CreateAndSaveReportObjectAfterReportCreation() + { + _pdfReport.LastSaved = DateTime.Now; + _pdfReport.LastGenerated = DateTime.Now; + await SavePDFReportDataToDisk(_pdfReport); + } + + private async Task SavePDFReportDataToDisk(PDFReport report) + { + var savePath = GetReportSavedDataPath(); + await Utilities.SaveReportDataAsync(report, savePath); + LogInfo("Saved report information to {0}", savePath); + HasUnsavedWork = false; + UpdateRecentlyUsed?.UpdateRecentlyUsed(report); + } + + // called from UI button + 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!"); + } + } + + private async Task DeterminePDFSaveLocation() + { + var fileName = ReportTitle + ".pdf"; + switch (_settings.PDFOutputSaveLocation) + { + case PDFSaveLocation.BaseFolder: + return Path.Combine(_pdfReport.BaseFolder, fileName); + case PDFSaveLocation.AlwaysAsk: + Func> getSaveFilePath = async () => + { + var topLevel = TopLevelGrabber?.GetTopLevel(); + if (topLevel != null) + { + var result = await topLevel.StorageProvider.SaveFilePickerWithResultAsync(new FilePickerSaveOptions + { + Title = "Choose PDF save location...", + FileTypeChoices = [FilePickerFileTypes.Pdf], + SuggestedFileType = FilePickerFileTypes.Pdf, + DefaultExtension = "pdf", + ShowOverwritePrompt = true, + SuggestedFileName = ReportTitle + ".pdf" + }); + + if (result.File != null) + { + // Console.WriteLine("1: {0}", result.File.Path.AbsolutePath); // HTML escaped?! + // Console.WriteLine("2: {0}", result.File.Path.AbsoluteUri); // starts with file:// + // Console.WriteLine("3: {0}", result.File.Path.LocalPath); // path for OS + // Console.WriteLine("4: {0}", result.File.Path.OriginalString); // starts with file:// + // Console.WriteLine("5: {0}", result.File.Path.UserEscaped); // bool + // Console.WriteLine("6: {0}", result.File.Name); // just file name, no path + var path = result.File.Path.LocalPath; + if (!path.EndsWith(".pdf")) + { + // should be fine, but juuuust in case... + path += ".pdf"; + } + // Console.WriteLine(path); + return path; + } + } + return null; + }; + // must invoke on UI thread because getting file picker + return await Dispatcher.UIThread.InvokeAsync(getSaveFilePath); + case PDFSaveLocation.OtherChosenDir: + return Path.Combine(_settings.OutputPdfDir, fileName); + } + return null; + } + + private async Task CreatePDF(string outputFilePath) + { + IsCreatingPDF = true; + var reportCreator = new ReportPDFCreator(this); + var outputPdfFile = await reportCreator.CreatePDF(ReportFiles.ToList(), ReportTitle, outputFilePath, new PDFFontResolver(_processDir, this), _settings); + if (!string.IsNullOrWhiteSpace(outputPdfFile)) + { + await CreateAndSaveReportObjectAfterReportCreation(); + OpenFolderForFileInFileViewer(outputPdfFile); + } + IsCreatingPDF = false; + } + + public async void ReturnToMainMenu() + { + if (await CheckIsSafeToShutdown()) + { + PopViewModel(); + } + } + + public async Task CheckIsSafeToShutdown() + { + if (!HasUnsavedWork) + { + 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/ViewModels/EditFileViewModel.cs b/src/MayShow.Shared/ViewModels/EditFileViewModel.cs similarity index 100% rename from src/ViewModels/EditFileViewModel.cs rename to src/MayShow.Shared/ViewModels/EditFileViewModel.cs diff --git a/src/ViewModels/MainWindowViewModel.cs b/src/MayShow.Shared/ViewModels/MainViewModel.cs similarity index 81% rename from src/ViewModels/MainWindowViewModel.cs rename to src/MayShow.Shared/ViewModels/MainViewModel.cs index 5885e41..25f725a 100644 --- a/src/ViewModels/MainWindowViewModel.cs +++ b/src/MayShow.Shared/ViewModels/MainViewModel.cs @@ -1,20 +1,18 @@ using MayShow.Helpers; using MayShow.Interfaces; -using System; using System.Collections.Generic; -using System.Text; namespace MayShow.ViewModels; -class MainWindowViewModel : ChangeNotifier, IChangeViewModel +class MainViewModel : ChangeNotifier, IChangeViewModel { BaseViewModel _currentViewModel; Stack _viewModels; - public MainWindowViewModel(ITopLevelGrabber topLevelGrabber) + public MainViewModel(ITopLevelGrabber topLevelGrabber): base() { _viewModels = new Stack(); - var initialViewModel = new MainViewModel(this) + var initialViewModel = new StartNewChooseReportViewModel(this) { TopLevelGrabber = topLevelGrabber }; diff --git a/src/ViewModels/SettingsViewModel.cs b/src/MayShow.Shared/ViewModels/SettingsViewModel.cs similarity index 83% rename from src/ViewModels/SettingsViewModel.cs rename to src/MayShow.Shared/ViewModels/SettingsViewModel.cs index ebab7cb..08f90f1 100644 --- a/src/ViewModels/SettingsViewModel.cs +++ b/src/MayShow.Shared/ViewModels/SettingsViewModel.cs @@ -1,24 +1,14 @@ #nullable enable using System; -using System.Collections.ObjectModel; -using System.Globalization; using System.IO; -using System.Linq; -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.Interfaces; using MayShow.Models; using MayShow.Helpers; using System.Collections.Generic; +using MayShow.Enums; namespace MayShow.ViewModels; @@ -32,7 +22,7 @@ class SettingsViewModel: ChangeNotifier private int _gridDisplayDateFormatSelectedIndex; private int _reportDisplayDateFormatSelectedIndex; - public SettingsViewModel(Settings settingsToEdit, ITopLevelGrabber? topLevelGrabber) + public SettingsViewModel(Settings settingsToEdit, ITopLevelGrabber? topLevelGrabber): base() { _previousSettings = settingsToEdit; _settings = new Settings(settingsToEdit); // clone it @@ -61,13 +51,19 @@ class SettingsViewModel: ChangeNotifier } } - public bool SaveOutputPdfInWorkingDir + public bool SaveOutputPdfInChosenDir { - get => _settings.SaveOutputPdfInWorkingDir; + get => PDFOutputSaveLocation == PDFSaveLocation.OtherChosenDir; + } + + public PDFSaveLocation PDFOutputSaveLocation + { + get => _settings.PDFOutputSaveLocation; set { - _settings.SaveOutputPdfInWorkingDir = value; + _settings.PDFOutputSaveLocation = value; NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(SaveOutputPdfInChosenDir)); } } @@ -84,7 +80,7 @@ class SettingsViewModel: ChangeNotifier public bool IsOutputPdfDirValid { - get => SaveOutputPdfInWorkingDir || (!SaveOutputPdfInWorkingDir && Directory.Exists(OutputPdfDirPath)); + get => !SaveOutputPdfInChosenDir || (SaveOutputPdfInChosenDir && Directory.Exists(OutputPdfDirPath)); } public bool HasErrorMessage @@ -113,16 +109,6 @@ class SettingsViewModel: ChangeNotifier } } - public bool SaveReportJsonDataInInternalDir - { - get => _settings.SaveReportJsonDataInInternalDir; - set - { - _settings.SaveReportJsonDataInInternalDir = value; - NotifyPropertyChanged(); - } - } - public List DateFormats { get => _dateFormats; @@ -171,7 +157,6 @@ class SettingsViewModel: ChangeNotifier public void OpenSettingsDir() { var topLevel = _topLevelGrabber?.GetTopLevel(); - Console.WriteLine(Utilities.GetInternalDataPath()); var dirName = Utilities.GetInternalDataPath(); if (topLevel is not null && dirName != null) { diff --git a/src/ViewModels/ShutdownCheckViewModel.cs b/src/MayShow.Shared/ViewModels/ShutdownCheckViewModel.cs similarity index 100% rename from src/ViewModels/ShutdownCheckViewModel.cs rename to src/MayShow.Shared/ViewModels/ShutdownCheckViewModel.cs diff --git a/src/MayShow.Shared/ViewModels/StartNewChooseReportViewModel.cs b/src/MayShow.Shared/ViewModels/StartNewChooseReportViewModel.cs new file mode 100644 index 0000000..c820625 --- /dev/null +++ b/src/MayShow.Shared/ViewModels/StartNewChooseReportViewModel.cs @@ -0,0 +1,142 @@ +#nullable enable + +using System.Collections.ObjectModel; +using System.Linq; +using DialogHostAvalonia; +using MayShow.Interfaces; +using MayShow.Models; +using MayShow.Helpers; +using System.Threading.Tasks; + +namespace MayShow.ViewModels; + +class StartNewChooseReportViewModel : BaseViewModel, ICanCheckShutdown, IUpdateRecentlyUsed +{ + private string _creatingReportTitle; + private ObservableCollection _savedReports; + private Settings _settings; + + public StartNewChooseReportViewModel(IChangeViewModel viewModelChanger) : base(viewModelChanger) + { + _creatingReportTitle = ""; + _settings = Settings.LoadSettings(); + _savedReports = new ObservableCollection(_settings.AllReportInfo.OrderBy(x => x.Title)); + } + + public static string Version + { + get => Constants.AppVersion; + } + + public string CreatingReportTitle + { + get => _creatingReportTitle; + set { _creatingReportTitle = value; NotifyPropertyChanged(); } + } + + public ObservableCollection SavedReports + { + get => _savedReports; + set { _savedReports = value; NotifyPropertyChanged(); } + } + + public async void StartReport() // start a new report based on a title alone + { + if (string.IsNullOrWhiteSpace(CreatingReportTitle)) + { + await DialogHost.Show(new WarningViewModel("Report title cannot be blank!")); + return; + } + var reportInfo = new PDFReportInfo() + { + Title = CreatingReportTitle, + LastSaved = null, + UUID = Utilities.GetUniqueReportGuid(_settings).ToString() + }; + reportInfo.UpdateBaseFolder(); + // now update UI + ViewModelChanger.PushViewModel(new CreatePDFReportViewModel(reportInfo, ViewModelChanger) + { + UpdateRecentlyUsed = this, + TopLevelGrabber = TopLevelGrabber + }); + CreatingReportTitle = ""; // when user comes back they can start another new report + } + + public void LoadExistingReport(object info) => LoadExistingReportImpl((PDFReportInfo) info); + public void LoadExistingReportImpl(PDFReportInfo reportInfo) + { + ViewModelChanger.PushViewModel(new CreatePDFReportViewModel(reportInfo, ViewModelChanger) + { + UpdateRecentlyUsed = this, + TopLevelGrabber = TopLevelGrabber + }); + } + + public void DeleteExistingReport(object info) => DeleteExistingReportImpl((PDFReportInfo) info); + public async void DeleteExistingReportImpl(PDFReportInfo reportInfo) + { + var message = string.IsNullOrWhiteSpace(reportInfo.BaseFolder) + ? "Are you sure you want to delete this report and its associated data? It will be gone forever!" + : "Are you sure you want to delete information about this report? It will be gone forever!"; + var result = await DialogHost.Show(new ConfirmViewModel( + "Warning!", + message, + "Delete Report", + "Cancel") + { + ConfirmButtonUsesDangerStyle = true, + ConfirmTitleIcon = "\uf1f8;" + }); + if (result != null && (bool)result) + { + SavedReports.Remove(reportInfo); + _settings.AllReportInfo.Remove(reportInfo); + reportInfo.DeleteInternalFolderFromDisk(); // delete internal data if available + await _settings.SaveSettingsAsync(); // update saved items list + } + } + + 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(); + } + } + + public async Task CheckIsSafeToShutdown() + { + return true; + } + + public async void UpdateRecentlyUsed(PDFReport report) + { + var didFind = false; + foreach (var existing in _settings.AllReportInfo) + { + if (existing.UUID == report.UUID) + { + didFind = true; + // update info on existing object + existing.LastSaved = report.LastSaved; + existing.Title = report.Title; + existing.BaseFolder = report.BaseFolder; + } + } + if (!didFind) + { + _settings.AllReportInfo.Add(report); + } + // ... this sort and save is slow, technically, but we're not going to have millions of items here, so... + SavedReports = new ObservableCollection(_settings.AllReportInfo.OrderBy(x => x.Title)); + await _settings.SaveSettingsAsync(); + } +} \ No newline at end of file diff --git a/src/ViewModels/WarningDeleteItemViewModel.cs b/src/MayShow.Shared/ViewModels/WarningDeleteItemViewModel.cs similarity index 87% rename from src/ViewModels/WarningDeleteItemViewModel.cs rename to src/MayShow.Shared/ViewModels/WarningDeleteItemViewModel.cs index 589e8f2..22e4263 100644 --- a/src/ViewModels/WarningDeleteItemViewModel.cs +++ b/src/MayShow.Shared/ViewModels/WarningDeleteItemViewModel.cs @@ -8,7 +8,7 @@ class WarningDeleteItemViewModel : ChangeNotifier { ReportFile _file; - public WarningDeleteItemViewModel(ReportFile file) + public WarningDeleteItemViewModel(ReportFile file): base() { _file = file; } diff --git a/src/ViewModels/WarningViewModel.cs b/src/MayShow.Shared/ViewModels/WarningViewModel.cs similarity index 100% rename from src/ViewModels/WarningViewModel.cs rename to src/MayShow.Shared/ViewModels/WarningViewModel.cs diff --git a/src/Views/AboutView.axaml b/src/MayShow.Shared/Views/AboutView.axaml similarity index 100% rename from src/Views/AboutView.axaml rename to src/MayShow.Shared/Views/AboutView.axaml diff --git a/src/Views/AboutView.axaml.cs b/src/MayShow.Shared/Views/AboutView.axaml.cs similarity index 100% rename from src/Views/AboutView.axaml.cs rename to src/MayShow.Shared/Views/AboutView.axaml.cs diff --git a/src/Views/ConfirmView.axaml b/src/MayShow.Shared/Views/ConfirmView.axaml similarity index 74% rename from src/Views/ConfirmView.axaml rename to src/MayShow.Shared/Views/ConfirmView.axaml index 94125fd..0a4a43c 100644 --- a/src/Views/ConfirmView.axaml +++ b/src/MayShow.Shared/Views/ConfirmView.axaml @@ -20,16 +20,21 @@ MaxWidth="350" Text="{Binding Message}"/> diff --git a/src/Views/ConfirmView.axaml.cs b/src/MayShow.Shared/Views/ConfirmView.axaml.cs similarity index 100% rename from src/Views/ConfirmView.axaml.cs rename to src/MayShow.Shared/Views/ConfirmView.axaml.cs diff --git a/src/Views/MainView.axaml b/src/MayShow.Shared/Views/CreatePDFReportView.axaml similarity index 83% rename from src/Views/MainView.axaml rename to src/MayShow.Shared/Views/CreatePDFReportView.axaml index a3a05eb..6ac873b 100644 --- a/src/Views/MainView.axaml +++ b/src/MayShow.Shared/Views/CreatePDFReportView.axaml @@ -3,62 +3,36 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="MayShow.Views.MainView" + x:Class="MayShow.Views.CreatePDFReportView" xmlns:helpers="clr-namespace:MayShow.Helpers" xmlns:models="clr-namespace:MayShow.Models" xmlns:views="clr-namespace:MayShow.Views" xmlns:vm="clr-namespace:MayShow.ViewModels" xmlns:progRing="clr-namespace:AvaloniaProgressRing;assembly=AvaloniaProgressRing" - x:DataType="vm:MainViewModel"> + x:DataType="vm:CreatePDFReportViewModel"> - - - @@ -100,7 +74,7 @@ - - - diff --git a/src/Views/MainView.axaml.cs b/src/MayShow.Shared/Views/CreatePDFReportView.axaml.cs similarity index 83% rename from src/Views/MainView.axaml.cs rename to src/MayShow.Shared/Views/CreatePDFReportView.axaml.cs index 8b5e590..0d7ba29 100644 --- a/src/Views/MainView.axaml.cs +++ b/src/MayShow.Shared/Views/CreatePDFReportView.axaml.cs @@ -7,9 +7,9 @@ using MayShow.ViewModels; namespace MayShow.Views; -public partial class MainView : UserControl +public partial class CreatePDFReportView : UserControl { - public MainView() + public CreatePDFReportView() { this.InitializeComponent(); LogBlock.PropertyChanged += LogBlock_PropertyChanged; @@ -28,7 +28,7 @@ public partial class MainView : UserControl { var topLevel = TopLevel.GetTopLevel(this); topLevel?.FocusManager?.ClearFocus(); - if (DataContext is MainViewModel mvm) + if (DataContext is CreatePDFReportViewModel mvm) { mvm?.HasUnsavedWork = true; } @@ -36,7 +36,7 @@ public partial class MainView : UserControl private void FileCellEditEnded(object? sender, DataGridCellEditEndedEventArgs args) { - if (args.EditAction == DataGridEditAction.Commit && DataContext is MainViewModel mvm) + if (args.EditAction == DataGridEditAction.Commit && DataContext is CreatePDFReportViewModel mvm) { mvm?.HasUnsavedWork = true; } diff --git a/src/MayShow.Shared/Views/EditFile.axaml b/src/MayShow.Shared/Views/EditFile.axaml new file mode 100644 index 0000000..2902db7 --- /dev/null +++ b/src/MayShow.Shared/Views/EditFile.axaml @@ -0,0 +1,62 @@ + + + + \ No newline at end of file diff --git a/src/Views/EditFile.axaml.cs b/src/MayShow.Shared/Views/EditFile.axaml.cs similarity index 100% rename from src/Views/EditFile.axaml.cs rename to src/MayShow.Shared/Views/EditFile.axaml.cs diff --git a/src/MayShow.Shared/Views/MainView.axaml b/src/MayShow.Shared/Views/MainView.axaml new file mode 100644 index 0000000..7dadd06 --- /dev/null +++ b/src/MayShow.Shared/Views/MainView.axaml @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/src/MayShow.Shared/Views/MainView.axaml.cs b/src/MayShow.Shared/Views/MainView.axaml.cs new file mode 100644 index 0000000..37fc41a --- /dev/null +++ b/src/MayShow.Shared/Views/MainView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace MayShow.Views; + +public partial class MainView : UserControl +{ + public MainView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Views/SettingsView.axaml b/src/MayShow.Shared/Views/SettingsView.axaml similarity index 71% rename from src/Views/SettingsView.axaml rename to src/MayShow.Shared/Views/SettingsView.axaml index 35dd693..c0ea227 100644 --- a/src/Views/SettingsView.axaml +++ b/src/MayShow.Shared/Views/SettingsView.axaml @@ -7,9 +7,14 @@ d:DesignHeight="450" x:Class="MayShow.Views.SettingsView" xmlns:models="clr-namespace:MayShow.Models" + xmlns:enums="clr-namespace:MayShow.Enums" xmlns:vm="clr-namespace:MayShow.ViewModels" + xmlns:converters="using:Avalonia.Controls.Converters" x:DataType="vm:SettingsViewModel" MaxWidth="450"> + + + Use legacy PDF handling (does not work with macOS annotations) - Always save report PDF in working directory + + + + + + + + + + + + - + + + MayShow + + + + \ No newline at end of file diff --git a/src/MayShow.Shared/Views/StartNewChooseReport.axaml.cs b/src/MayShow.Shared/Views/StartNewChooseReport.axaml.cs new file mode 100644 index 0000000..272e849 --- /dev/null +++ b/src/MayShow.Shared/Views/StartNewChooseReport.axaml.cs @@ -0,0 +1,14 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace MayShow.Views; + +public partial class StartNewChooseReport : UserControl +{ + public StartNewChooseReport() + { + this.InitializeComponent(); + } +} diff --git a/src/Views/WarningDeleteItem.axaml b/src/MayShow.Shared/Views/WarningDeleteItem.axaml similarity index 100% rename from src/Views/WarningDeleteItem.axaml rename to src/MayShow.Shared/Views/WarningDeleteItem.axaml diff --git a/src/Views/WarningDeleteItem.axaml.cs b/src/MayShow.Shared/Views/WarningDeleteItem.axaml.cs similarity index 100% rename from src/Views/WarningDeleteItem.axaml.cs rename to src/MayShow.Shared/Views/WarningDeleteItem.axaml.cs diff --git a/src/Views/WarningView.axaml b/src/MayShow.Shared/Views/WarningView.axaml similarity index 100% rename from src/Views/WarningView.axaml rename to src/MayShow.Shared/Views/WarningView.axaml diff --git a/src/Views/WarningView.axaml.cs b/src/MayShow.Shared/Views/WarningView.axaml.cs similarity index 100% rename from src/Views/WarningView.axaml.cs rename to src/MayShow.Shared/Views/WarningView.axaml.cs diff --git a/src/MayShow.iOS/AppDelegate.cs b/src/MayShow.iOS/AppDelegate.cs new file mode 100644 index 0000000..dead003 --- /dev/null +++ b/src/MayShow.iOS/AppDelegate.cs @@ -0,0 +1,23 @@ +using Foundation; +using UIKit; +using Avalonia; +using Avalonia.Controls; +using Avalonia.iOS; +using Avalonia.Media; + +namespace MayShow.iOS; + +// The UIApplicationDelegate for the application. This class is responsible for launching the +// User Interface of the application, as well as listening (and optionally responding) to +// application events from iOS. +[Register("AppDelegate")] +#pragma warning disable CA1711 // Identifiers should not have incorrect suffix +public partial class AppDelegate : AvaloniaAppDelegate +#pragma warning restore CA1711 // Identifiers should not have incorrect suffix +{ + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) + { + return base.CustomizeAppBuilder(builder) + .WithInterFont(); + } +} diff --git a/src/MayShow.iOS/Entitlements.plist b/src/MayShow.iOS/Entitlements.plist new file mode 100644 index 0000000..c2cba09 --- /dev/null +++ b/src/MayShow.iOS/Entitlements.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/src/MayShow.iOS/Info.plist b/src/MayShow.iOS/Info.plist new file mode 100644 index 0000000..a102025 --- /dev/null +++ b/src/MayShow.iOS/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDisplayName + MayShow + CFBundleIdentifier + companyName.MayShow + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0 + LSRequiresIPhoneOS + + MinimumOSVersion + 13.0 + UIDeviceFamily + + 1 + 2 + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/src/MayShow.iOS/Main.cs b/src/MayShow.iOS/Main.cs new file mode 100644 index 0000000..81aebc4 --- /dev/null +++ b/src/MayShow.iOS/Main.cs @@ -0,0 +1,14 @@ +using UIKit; + +namespace MayShow.iOS; + +public class Application +{ + // This is the main entry point of the application. + static void Main(string[] args) + { + // if you want to use a different Application Delegate class from "AppDelegate" + // you can specify it here. + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/src/MayShow.iOS/MayShow.iOS.csproj b/src/MayShow.iOS/MayShow.iOS.csproj new file mode 100644 index 0000000..b4f0301 --- /dev/null +++ b/src/MayShow.iOS/MayShow.iOS.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0-ios + 13.0 + enable + True + + + + + + + + + + diff --git a/src/MayShow.iOS/Resources/LaunchScreen.xib b/src/MayShow.iOS/Resources/LaunchScreen.xib new file mode 100644 index 0000000..c879ae7 --- /dev/null +++ b/src/MayShow.iOS/Resources/LaunchScreen.xib @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MayShow.slnx b/src/MayShow.slnx new file mode 100644 index 0000000..c3af9e2 --- /dev/null +++ b/src/MayShow.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Models/PDFReport.cs b/src/Models/PDFReport.cs deleted file mode 100644 index 3b0b769..0000000 --- a/src/Models/PDFReport.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using MayShow.Helpers; - -namespace MayShow.Models; - -class PDFReport : ChangeNotifier -{ - private string _baseFolder; - private string _title; - private List _files; - private DateTime _lastSaved; - private DateTime? _lastGenerated; - - public PDFReport() - { - _baseFolder = ""; - _title = ""; - _files = []; - _lastSaved = DateTime.Now; - _lastGenerated = null; - } - - public string BaseFolder - { - get => _baseFolder; - set { _baseFolder = value; NotifyPropertyChanged(); } - } - - public string Title - { - get => _title; - set { _title = value; NotifyPropertyChanged(); } - } - - public List Files - { - get => _files; - set { _files = value; NotifyPropertyChanged(); } - } - - public DateTime LastSaved - { - get => _lastSaved; - set { _lastSaved = value; NotifyPropertyChanged(); } - } - - public DateTime? LastGenerated - { - get => _lastGenerated; - set { _lastGenerated = value; NotifyPropertyChanged(); } - } -} \ No newline at end of file diff --git a/src/Models/Settings.cs b/src/Models/Settings.cs deleted file mode 100644 index d457b10..0000000 --- a/src/Models/Settings.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using MayShow.Helpers; - -namespace MayShow.Models; - -class Settings : ChangeNotifier -{ - private string _lastUsedPath; - private bool _useDocnetPDFImageRendering; - private bool _saveOutputPdfInWorkingDir; - private string _outputPdfDir; - private decimal _imageResizeThreshold; - private bool _saveReportJsonDataInInternalDir; - private Dictionary _workingFolderToInternalFolderName; - public string _dataGridDateFormat; - public string _reportDateFormat; - public int _settingsVersion; - - public Settings() - { - _lastUsedPath = ""; - _useDocnetPDFImageRendering = true; - _saveOutputPdfInWorkingDir = true; - _outputPdfDir = ""; - _imageResizeThreshold = 1.5m; - _saveReportJsonDataInInternalDir = false; - _workingFolderToInternalFolderName = []; - _settingsVersion = 1; - _dataGridDateFormat = "dd/MM/yyyy"; - _reportDateFormat = "yyyy-MM-dd"; - } - - public Settings(Settings other) - { - _lastUsedPath = other.LastUsedPath; - _useDocnetPDFImageRendering = other.UseDocnetPDFImageRendering; - _saveOutputPdfInWorkingDir = other.SaveOutputPdfInWorkingDir; - _outputPdfDir = other.OutputPdfDir; - _imageResizeThreshold = other.ImageResizeThreshold; - _saveReportJsonDataInInternalDir = other.SaveReportJsonDataInInternalDir; - _workingFolderToInternalFolderName = other.WorkingFolderToInternalFolderName; - _settingsVersion = other.SettingsVersion; - _dataGridDateFormat = "yyyy-MM-dd"; - _reportDateFormat = "yyyy-MM-dd"; - } - - [JsonInclude] - public string LastUsedPath - { - get => _lastUsedPath; - set { _lastUsedPath = value; NotifyPropertyChanged(); } - } - - [JsonInclude] - [JsonPropertyName("UseDocnetPFDImageRendering")] // ...this typo now has to live because people have this saved on disk... - public bool UseDocnetPDFImageRendering - { - get => _useDocnetPDFImageRendering; - set { _useDocnetPDFImageRendering = value; NotifyPropertyChanged(); } - } - - [JsonInclude] - public bool SaveOutputPdfInWorkingDir - { - get => _saveOutputPdfInWorkingDir; - set { _saveOutputPdfInWorkingDir = value; NotifyPropertyChanged(); } - } - - [JsonInclude] - public string OutputPdfDir - { - get => _outputPdfDir; - set { _outputPdfDir = value; NotifyPropertyChanged(); } - } - - [JsonInclude] - public decimal ImageResizeThreshold - { - get => _imageResizeThreshold; - set { _imageResizeThreshold = value; NotifyPropertyChanged(); } - } - - [JsonInclude] - public bool SaveReportJsonDataInInternalDir - { - get => _saveReportJsonDataInInternalDir; - set { _saveReportJsonDataInInternalDir = value; NotifyPropertyChanged(); } - } - - [JsonInclude] - public Dictionary WorkingFolderToInternalFolderName - { - get => _workingFolderToInternalFolderName; - set { _workingFolderToInternalFolderName = value; NotifyPropertyChanged(); } - } - - [JsonInclude] - public int SettingsVersion - { - get => _settingsVersion; - set { _settingsVersion = value; NotifyPropertyChanged(); } - } - - [JsonInclude] - public string DataGridDateFormat - { - get => _dataGridDateFormat; - set { _dataGridDateFormat = value; NotifyPropertyChanged(); } - } - - [JsonInclude] - public string ReportDateFormat - { - get => _reportDateFormat; - set { _reportDateFormat = value; NotifyPropertyChanged(); } - } - - public static string SettingsFileName = "settings.json"; - - public static string GetSettingsPath() - { - var path = Utilities.GetInternalDataPath(); - return Path.Combine(path, SettingsFileName); - } - - - public string SaveSettingsNotAsync() - { - var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); - using MemoryStream memoryStream = new MemoryStream(); - JsonSerializer.Serialize(memoryStream, this, jsonContext.Settings); - memoryStream.Position = 0; - using var reader = new StreamReader(memoryStream); - var json = reader.ReadToEnd(); - File.WriteAllText(GetSettingsPath(), json); - return json; - } - - public async Task SaveSettingsAsync() - { - var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); - using MemoryStream memoryStream = new MemoryStream(); - await JsonSerializer.SerializeAsync(memoryStream, this, jsonContext.Settings); - memoryStream.Position = 0; - using var reader = new StreamReader(memoryStream); - var json = await reader.ReadToEndAsync(); - await File.WriteAllTextAsync(GetSettingsPath(), json); - return json; - } - - public static Settings LoadSettings() - { - var path = GetSettingsPath(); - if (!File.Exists(path)) - { - return new Settings(); - } - var json = File.ReadAllText(GetSettingsPath()); - var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); - return JsonSerializer.Deserialize(json, jsonContext.Settings) ?? new Settings(); - } - - public static async Task LoadSettingsAsync() - { - using FileStream fileStream = File.OpenRead(GetSettingsPath()); - var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); - var output = await JsonSerializer.DeserializeAsync(fileStream, jsonContext.Settings) ?? new Settings(); - return output; - } -} \ No newline at end of file diff --git a/src/ViewModels/AboutViewModel.cs b/src/ViewModels/AboutViewModel.cs deleted file mode 100644 index 7ff38d3..0000000 --- a/src/ViewModels/AboutViewModel.cs +++ /dev/null @@ -1,33 +0,0 @@ -#nullable enable - -using System; -using System.Collections.ObjectModel; -using System.Globalization; -using System.IO; -using System.Linq; -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.Interfaces; -using MayShow.Models; - -namespace MayShow.ViewModels; - -class AboutViewModel -{ - public AboutViewModel() - { - } - - public void Close() - { - DialogHost.Close("DialogHost", null); - } -} \ No newline at end of file diff --git a/src/ViewModels/MainViewModel.cs b/src/ViewModels/MainViewModel.cs deleted file mode 100644 index acbd6b7..0000000 --- a/src/ViewModels/MainViewModel.cs +++ /dev/null @@ -1,1088 +0,0 @@ -#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 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 MigraDoc.DocumentObjectModel.Visitors; -using System.Collections.Generic; -using System.Runtime.InteropServices; - -namespace MayShow.ViewModels; - -class MainViewModel : 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 List _dateDisplayFormats; - - private bool _hasUnsavedWork; - - public MainViewModel(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); - var compDetails = RuntimeInformation.OSDescription + " | " + - RuntimeInformation.OSArchitecture.ToString(); - _programLog = "----- MayShow v" + Constants.AppVersion + " | " + compDetails + " ------" + 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(); - _dateDisplayFormats = Constants.GetDateDisplayFormats(); - NotifyPropertyChanged(nameof(DataGridDateFormat)); - NotifyPropertyChanged(nameof(DataGridDateFormatWatermark)); - 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; - }; - } - } - - public string DataGridDateFormat - { - get => _settings.DataGridDateFormat; - } - - public string DataGridDateFormatWatermark - { - get => _dateDisplayFormats.FirstOrDefault(x => x.Value == _settings.DataGridDateFormat)?.Example ?? "2025-12-04"; - } - - 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!"); - NotifyPropertyChanged(nameof(DataGridDateFormat)); - NotifyPropertyChanged(nameof(DataGridDateFormatWatermark)); - } - } - - 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); - } - var otherException = e.InnerException; - while (otherException != null) - { - LogInfo(">> Inner exception: " + otherException.Message); - if (otherException.StackTrace != null) - { - LogInfo(otherException.StackTrace); - } - otherException = otherException.InnerException; - } - 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") - { - var path = Path.Combine(_processDir, "Assets/Fonts/Noto_Sans/static/NotoSans-Regular.ttf"); - if (!File.Exists(path)) - { - path = Path.Combine(_processDir, "../Resources/Assets/Fonts/Noto_Sans/static/NotoSans-Regular.ttf"); - } - return File.ReadAllBytes(path); - } - if (faceName == "Noto Sans Bold") - { - var path = Path.Combine(_processDir, "Assets/Fonts/Noto_Sans/static/NotoSans-Bold.ttf"); - if (!File.Exists(path)) - { - path = Path.Combine(_processDir, "../Resources/Assets/Fonts/Noto_Sans/static/NotoSans-Bold.ttf"); - } - return File.ReadAllBytes(path); - } - 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") - { - if (bold) - { - return new FontResolverInfo(familyName + " Bold"); - } - return new FontResolverInfo(familyName); - } - if (familyName == "Noto Sans JP") - { - if (bold) - { - return new FontResolverInfo(familyName + " Bold"); - } - return new FontResolverInfo(familyName); - } - return null; - } - - private Paragraph GetFooterParagraph() - { - 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")); - footerPar.Tag = "FooterPar"; - footerPar.Format.Font.Name = "Noto Sans"; - return footerPar; - } - - private decimal GetExistingPageItemHeight(PdfDocumentRenderer pdfRenderer, decimal footerParagraphHeight) - { - pdfRenderer.DocumentRenderer.PrepareDocument(); - var currPageCount = pdfRenderer.DocumentRenderer.FormattedDocument?.PageCount; - var heightForExistingItemsOnPage = footerParagraphHeight; - if (currPageCount.HasValue) - { - var renderInfo = pdfRenderer.DocumentRenderer.GetRenderInfoFromPage(currPageCount.Value); - if (renderInfo != null) - { - // Console.WriteLine("Got render info for page: {0}", currPageCount); - foreach (var item in renderInfo) - { - heightForExistingItemsOnPage += (decimal)item.LayoutInfo.ContentArea.Height.Inch; - } - } - } - return heightForExistingItemsOnPage; - } - - // 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; - } - // setup globals and consts... - GlobalFontSettings.FontResolver = this; - GlobalFontSettings.FallbackFontResolver = new FailsafeFontResolver(); - const decimal pageWidth = 8.5m; - const decimal pageHeight = 11.0m; - const decimal margin = 0.5m; - const int imageResolution = 72; - const int imageInsertMarginPixels = 30; // we calculate max available; use max - this # for max image size - var maxItemPxWidth = ((pageWidth - (2 * margin)) * imageResolution) - imageInsertMarginPixels; - // start making PDF! - IsCreatingPDF = true; - var pdfDoc = new Document(); - var outputFileName = ReportTitle + ".pdf"; - var folderName = new DirectoryInfo(folderPath).Name; - const int maxImageWidth = 425; - var imageLineFormat = new MigraDoc.DocumentObjectModel.Shapes.LineFormat() - { - Color = Colors.Black, - Width = Unit.FromPoint(2), - };; - 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); - } - } - // setup initial section (for page characteristics) - var section = pdfDoc.AddSection(); - section.PageSetup.PageFormat = PageFormat.Letter; - section.PageSetup.PageWidth = pageWidth + "in"; - section.PageSetup.PageHeight = pageHeight + "in"; - section.PageSetup.TopMargin = margin + "in"; - section.PageSetup.RightMargin = margin + "in"; - section.PageSetup.BottomMargin = margin + "in"; - section.PageSetup.LeftMargin = margin + "in"; - // setup footer for page number - var footerPar = GetFooterParagraph(); - section.Footers.Primary.Add(footerPar); - // create a quick PDF doc renderer to measure footer paragraph height - var footerParagraphHeight = 0.4m; // estimate - var footerOnlyPdfDoc = new Document(); - var sectionClone = section.Clone(); - footerOnlyPdfDoc.Add(sectionClone); - sectionClone.Add(GetFooterParagraph()); - var footerPdfRenderer = new PdfDocumentRenderer - { - Document = footerOnlyPdfDoc - }; - footerPdfRenderer.DocumentRenderer.PrepareDocument(); - var footerRenderInfo = footerPdfRenderer.DocumentRenderer.GetRenderInfoFromPage(1); - if (footerRenderInfo != null) - { - foreach (var item in footerRenderInfo) - { - if (item.DocumentObject.Tag?.ToString() == "FooterPar") - { - Console.WriteLine("Got footer paragraph height!"); - footerParagraphHeight = (decimal)item.LayoutInfo.ContentArea.Height.Inch; - break; - } - } - } - // continue setting up document - // First page only: 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); - reportTitlePar.Tag = "TitlePar"; - // get converted files directory path and create it if necessary - var convertedDir = Path.Combine(Utilities.GetInternalDataPath(), "converted"); - if (!Directory.Exists(convertedDir)) - { - Directory.CreateDirectory(convertedDir); - } - // - var pdfRenderer = new PdfDocumentRenderer - { - Document = pdfDoc, - WorkingDirectory = folderPath - }; - 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); - imageTitlePar.Tag = "ReceiptTitlePar"; - 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(_settings.ReportDateFormat)); - receiptDatePar.Tag = "ReceiptDatePar"; - 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); - imageNotesPar.Tag = "ReceiptNotesPar"; - } - var emptyPar = section.AddParagraph(); // add empty line for spacing - emptyPar.Tag = "EmptyParagraph"; - // 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; - // get max pixel height remaining for items on this page - // (For multi-page PDFs, showing page 2 and on will have more height since they have no title, - // but to keep things consistent we will use the same height for all PDF pages.) - // render up to now on this page and get height remaining in inches - var currPageCount = pdfRenderer.DocumentRenderer.FormattedDocument?.PageCount; - var heightForExistingItemsOnPage = GetExistingPageItemHeight(pdfRenderer, footerParagraphHeight); - var remainingHeightInches = pageHeight - (2 * margin) - heightForExistingItemsOnPage; - var remainingHeightPixels = (remainingHeightInches * imageResolution) - imageInsertMarginPixels; - 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.Resolution = imageResolution; // dots per inch - image.Tag = "ReceiptImageTag"; - paragraph.Tag = "ReceiptImageParagraphTag"; - image.LineFormat = imageLineFormat.Clone(); - // resize down until it will fit on the page - while (loadedImageHeight > remainingHeightPixels || loadedImageWidth > maxItemPxWidth) - { - // Console.WriteLine("Image height = {0}, width = {1}; decreasing size by 5% to h={2}, w={3}", loadedImageHeight, loadedImageWidth, (uint)Math.Floor(loadedImageHeight * 0.95), (uint)Math.Floor(loadedImageWidth * 0.95)); - // keep reducing size by 5% (little by little) until it fits on the page - // ...might skew ever so slightly but should not be noticable... - loadedImageHeight = (uint)Math.Floor(loadedImageHeight * 0.95); - loadedImageWidth = (uint)Math.Floor(loadedImageWidth * 0.95); - } - image.Height = loadedImageHeight; - image.Width = loadedImageWidth; - } - else // isPDF - { - // 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; - // get image height/width off of disk so we can resize down if needed - var image = paragraph.AddImage(convertedPdfImagePath); - image.LockAspectRatio = true; - image.LineFormat = imageLineFormat.Clone(); - using (var firstPdfPageImage = new MagickImage(convertedPdfImagePath)) - { - var pdfPageImageWidth = firstPdfPageImage.Width; - var pdfPageImageHeight = firstPdfPageImage.Height; - // resize down until it will fit on the page - while (pdfPageImageHeight > remainingHeightPixels || pdfPageImageWidth > maxItemPxWidth) - { - pdfPageImageHeight = (uint)Math.Floor(pdfPageImageHeight * 0.95); - pdfPageImageWidth = (uint)Math.Floor(pdfPageImageWidth * 0.95); - } - image.Height = pdfPageImageHeight; - image.Width = pdfPageImageWidth; - } - 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 = maxImageWidth; - image.LineFormat = imageLineFormat.Clone(); - using (var otherPdfPageImage = new MagickImage(convertedPdfImagePath)) - { - var pdfPageImageWidth = otherPdfPageImage.Width; - var pdfPageImageHeight = otherPdfPageImage.Height; - // resize down until it will fit on the page - while (pdfPageImageHeight > remainingHeightPixels || pdfPageImageWidth > maxItemPxWidth) - { - pdfPageImageHeight = (uint)Math.Floor(pdfPageImageHeight * 0.95); - pdfPageImageWidth = (uint)Math.Floor(pdfPageImageWidth * 0.95); - } - image.Height = pdfPageImageHeight; - image.Width = pdfPageImageWidth; - } - } - } - } - else - { - // use older, not-docnet rendering method. - // uses MigraDoc rendering. Does not work with annotations, and since Migradoc - // doesn't let us know how big the image is, we can't do the image resizing, so - // we just do our best. - // 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 = maxImageWidth; // can't be too wide now...not sure why...maybe due to margins... - image.LineFormat = imageLineFormat.Clone(); - // 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 = maxImageWidth; - image.LineFormat = imageLineFormat.Clone(); - } - } - } - LogInfo(string.Format("Added image: {0} ({1})", file.Title, filePath)); - hasAddedData = true; - } - LogInfo("Rendering document to PDF file..."); - pdfRenderer.DocumentRenderer.PrepareDocument(); // needed if you make edits after first PrepareDocument() is called - pdfRenderer.RenderDocument(); - // actually save to disk now - 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/Views/EditFile.axaml b/src/Views/EditFile.axaml deleted file mode 100644 index 4db5c22..0000000 --- a/src/Views/EditFile.axaml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - \ No newline at end of file