diff --git a/Elwig/App.xaml b/Elwig/App.xaml index 3c2eed1..844561d 100644 --- a/Elwig/App.xaml +++ b/Elwig/App.xaml @@ -2,8 +2,7 @@ x:Class="Elwig.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:ctrl="clr-namespace:Elwig.Controls" - Exit="Application_Exit"> + xmlns:ctrl="clr-namespace:Elwig.Controls"> diff --git a/Elwig/App.xaml.cs b/Elwig/App.xaml.cs index d723e83..5c139eb 100644 --- a/Elwig/App.xaml.cs +++ b/Elwig/App.xaml.cs @@ -25,9 +25,10 @@ namespace Elwig { protected static App CurrentApp; public static int NumWindows => CurrentApp.Windows.Count; - public static bool ForceShutdown { get; private set; } = false; + public static bool ForceShutdown { get; set; } = false; private readonly DispatcherTimer _autoUpdateTimer = new() { Interval = TimeSpan.FromHours(1) }; + private readonly DispatcherTimer _autoBackupTimer = new() { Interval = TimeSpan.FromMinutes(10) }; public readonly SerialPortWatcher SerialPortWatcher = new(); public static readonly string DataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Elwig"); @@ -74,7 +75,7 @@ namespace Elwig { Config = new(Path.GetFullPath(args[1])); } - ContextTimer.Tick += (object? sender, EventArgs evt) => { + ContextTimer.Tick += (sender, evt) => { var ch = CurrentLastWrite; if (ch > LastChanged) { LastChanged = ch; @@ -106,9 +107,8 @@ namespace Elwig { Dictionary branches = []; using (var ctx = new AppDbContext()) { - branches = ctx.FetchBranches() - .ToDictionaryAsync(b => b.Name.ToLower(), b => (b.ZwstId, b.Name, b.PostalDest?.AtPlz?.Plz, b.PostalDest?.AtPlz?.Ort.Name, b.Address, b.PhoneNr, b.FaxNr, b.MobileNr)) - .GetAwaiter().GetResult(); + branches = await ctx.FetchBranches() + .ToDictionaryAsync(b => b.Name.ToLower(), b => (b.ZwstId, b.Name, b.PostalDest?.AtPlz?.Plz, b.PostalDest?.AtPlz?.Ort.Name, b.Address, b.PhoneNr, b.FaxNr, b.MobileNr)); try { Client = new(ctx); } catch (Exception exc) { @@ -124,9 +124,23 @@ namespace Elwig { return Task.CompletedTask; }); - Utils.RunBackground("PDF Initialization", () => Pdf.Init()); + Utils.RunBackground("PDF Initialization", async () => Pdf.Init()); Utils.RunBackground("JSON Schema Initialization", BillingData.Init); + if (Config.BackupAuto) { + Utils.RunBackground("Automatisches Backup", async () => { + Directory.CreateDirectory(Config.BackupPath); + await Task.Delay(500); + await BackupService.TryDatabaseBackup(); + }); + _autoBackupTimer.Tick += new EventHandler((sender, evt) => { + Utils.RunBackground("Automatisches Backup", async () => { + await BackupService.TryDatabaseBackup(); + }); + }); + _autoBackupTimer.Start(); + } + if (Config.UpdateAuto && Config.UpdateUrl != null) { if (Utils.HasInternetConnectivity()) { Utils.RunBackground("Auto Updater", async () => { @@ -190,12 +204,21 @@ namespace Elwig { window.Show(); } - private async void Application_Exit(object sender, ExitEventArgs evt) { + public async Task OnClosing() { + try { + await BackupService.TryDatabaseBackup(); + } catch (Exception exc) { + InteractionService.ShowException("Automatisches Backup", exc); + } + } + + protected override void OnExit(ExitEventArgs evt) { SerialPortWatcher.Dispose(); foreach (var s in EventScales) { s.Dispose(); } - await Pdf.Cleanup(); + Pdf.Cleanup(); + base.OnExit(evt); } public static void SetBranch(Branch b) { diff --git a/Elwig/Documents/Document.cs b/Elwig/Documents/Document.cs index 7f378b4..282c7d0 100644 --- a/Elwig/Documents/Document.cs +++ b/Elwig/Documents/Document.cs @@ -252,7 +252,7 @@ namespace Elwig.Documents { public async Task Print(int copies = 1) { if (PdfPath == null) throw new InvalidOperationException("Pdf file has not been generated yet"); - await Pdf.Print(PdfPath, copies, IsDoublePaged); + Pdf.Print(PdfPath, copies, IsDoublePaged); } public void Show() { diff --git a/Elwig/Helpers/Config.cs b/Elwig/Helpers/Config.cs index 8341273..b314792 100644 --- a/Elwig/Helpers/Config.cs +++ b/Elwig/Helpers/Config.cs @@ -48,8 +48,17 @@ namespace Elwig.Helpers { public string? DatabaseLog = null; public string? Branch = null; public WeighingMode? WeighingMode; + + public bool BackupAuto = true; + public string BackupPath = Path.Combine(App.DataPath, "backups"); + public int BackupRetainHours = 36; + public int BackupRetainDays = 8; + public int BackupRetainWeeks = 6; + public int BackupRetainMonths = 18; + public string? UpdateUrl = null; public bool UpdateAuto = false; + public string? SyncUrl = null; public string SyncUsername = ""; public string SyncPassword = ""; @@ -85,8 +94,17 @@ namespace Elwig.Helpers { Debug = TrueValues.Contains(config["general:debug"]?.ToLower()); var weighing = config["general:weighing"]; WeighingMode = weighing != null && Enum.TryParse(weighing, true, out var w) ? w : null; + + BackupAuto = !FalseValues.Contains(config["backup:auto"]?.ToLower()); + BackupPath = Path.Combine(Path.GetDirectoryName(DatabaseFile) ?? App.DataPath, config["backup:path"] ?? "backups"); + BackupRetainHours = int.TryParse(config["backup:retain_hours"], out var hours) ? hours : 36; + BackupRetainDays = int.TryParse(config["backup:retain_days"], out var days) ? days : 8; + BackupRetainWeeks = int.TryParse(config["backup:retain_weeks"], out var weeks) ? weeks : 6; + BackupRetainMonths = int.TryParse(config["backup:retain_months"], out var months) ? months : 18; + UpdateUrl = config["update:url"]; UpdateAuto = TrueValues.Contains(config["update:auto"]?.ToLower()); + SyncUrl = config["sync:url"]; SyncUsername = config["sync:username"] ?? ""; SyncPassword = config["sync:password"] ?? ""; diff --git a/Elwig/Helpers/Export/Database.cs b/Elwig/Helpers/Export/Database.cs index 703eac2..ea21980 100644 --- a/Elwig/Helpers/Export/Database.cs +++ b/Elwig/Helpers/Export/Database.cs @@ -18,73 +18,85 @@ namespace Elwig.Helpers.Export { } public static async Task ExportSqlite(string filename, bool zipFile) { - if (zipFile) { - File.Delete(filename); - using var zip = ZipFile.Open(filename, ZipArchiveMode.Create); + var tmp = filename + ".tmp"; + try { + File.Delete(tmp); + if (zipFile) { + using var zip = ZipFile.Open(tmp, ZipArchiveMode.Create); - var version = zip.CreateEntry("version", CompressionLevel.NoCompression); - using (var writer = new StreamWriter(version.Open(), Utils.UTF8)) { - await writer.WriteAsync("elwig:1"); + var version = zip.CreateEntry("version", CompressionLevel.NoCompression); + using (var writer = new StreamWriter(version.Open(), Utils.UTF8)) { + await writer.WriteAsync("elwig:1"); + } + + var (applId, userVers, schemaVers, size) = await GetMeta(); + var meta = zip.CreateEntry("meta.json", CompressionLevel.NoCompression); + using (var writer = new StreamWriter(meta.Open(), Utils.UTF8)) { + var obj = new JsonObject { + ["timestamp"] = $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}", + ["zwstid"] = App.ZwstId, + ["device"] = Environment.MachineName, + ["database"] = new JsonObject { + ["application_id"] = applId, + ["user_version"] = userVers, + ["schema_version"] = schemaVers, + ["file_size"] = size, + }, + }; + await writer.WriteAsync(obj.ToJsonString(Utils.JsonOpts)); + } + + var db = zip.CreateEntryFromFile(App.Config.DatabaseFile, "database.sqlite3", CompressionLevel.SmallestSize); + } else { + File.Copy(App.Config.DatabaseFile, tmp); } - - var (applId, userVers, schemaVers, size) = await GetMeta(); - var meta = zip.CreateEntry("meta.json", CompressionLevel.NoCompression); - using (var writer = new StreamWriter(meta.Open(), Utils.UTF8)) { - var obj = new JsonObject { - ["timestamp"] = $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}", - ["zwstid"] = App.ZwstId, - ["device"] = Environment.MachineName, - ["database"] = new JsonObject { - ["application_id"] = applId, - ["user_version"] = userVers, - ["schema_version"] = schemaVers, - ["file_size"] = size, - }, - }; - await writer.WriteAsync(obj.ToJsonString(Utils.JsonOpts)); - } - - var db = zip.CreateEntryFromFile(App.Config.DatabaseFile, "database.sqlite3", CompressionLevel.SmallestSize); - } else { - File.Copy(App.Config.DatabaseFile, filename, true); + File.Move(tmp, filename, true); + } finally { + File.Delete(tmp); } } public static async Task ExportSql(string filename, bool zipFile) { - if (zipFile) { - File.Delete(filename); - using var zip = ZipFile.Open(filename, ZipArchiveMode.Create); + var tmp = filename + ".tmp"; + try { + File.Delete(tmp); + if (zipFile) { + using var zip = ZipFile.Open(tmp, ZipArchiveMode.Create); - var version = zip.CreateEntry("version", CompressionLevel.NoCompression); - using (var writer = new StreamWriter(version.Open(), Utils.UTF8)) { - await writer.WriteAsync("elwig:1"); - } + var version = zip.CreateEntry("version", CompressionLevel.NoCompression); + using (var writer = new StreamWriter(version.Open(), Utils.UTF8)) { + await writer.WriteAsync("elwig:1"); + } - var (applId, userVers, schemaVers, size) = await GetMeta(); - var meta = zip.CreateEntry("meta.json", CompressionLevel.NoCompression); - using (var writer = new StreamWriter(meta.Open(), Utils.UTF8)) { - var obj = new JsonObject { - ["timestamp"] = $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}", - ["zwstid"] = App.ZwstId, - ["device"] = Environment.MachineName, - ["database"] = new JsonObject { - ["application_id"] = applId, - ["user_version"] = userVers, - ["schema_version"] = schemaVers, - ["file_size"] = size, - }, - }; - await writer.WriteAsync(obj.ToJsonString(Utils.JsonOpts)); - } + var (applId, userVers, schemaVers, size) = await GetMeta(); + var meta = zip.CreateEntry("meta.json", CompressionLevel.NoCompression); + using (var writer = new StreamWriter(meta.Open(), Utils.UTF8)) { + var obj = new JsonObject { + ["timestamp"] = $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}", + ["zwstid"] = App.ZwstId, + ["device"] = Environment.MachineName, + ["database"] = new JsonObject { + ["application_id"] = applId, + ["user_version"] = userVers, + ["schema_version"] = schemaVers, + ["file_size"] = size, + }, + }; + await writer.WriteAsync(obj.ToJsonString(Utils.JsonOpts)); + } - var sql = zip.CreateEntry("database.sql", CompressionLevel.SmallestSize); - using (var writer = new StreamWriter(sql.Open(), Utils.UTF8)) { + var sql = zip.CreateEntry("database.sql", CompressionLevel.SmallestSize); + using (var writer = new StreamWriter(sql.Open(), Utils.UTF8)) { + await ExportSql(writer); + } + } else { + using var stream = File.OpenWrite(tmp); + using var writer = new StreamWriter(stream, Utils.UTF8); await ExportSql(writer); } - } else { - using var stream = File.OpenWrite(filename); - using var writer = new StreamWriter(stream, Utils.UTF8); - await ExportSql(writer); + File.Move(tmp, filename, true); + } finally { + File.Delete(tmp); } } diff --git a/Elwig/Helpers/Extensions.cs b/Elwig/Helpers/Extensions.cs index dd6c463..18f7310 100644 --- a/Elwig/Helpers/Extensions.cs +++ b/Elwig/Helpers/Extensions.cs @@ -1,6 +1,7 @@ using Microsoft.Data.Sqlite; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.IO.Compression; using System.IO.Hashing; @@ -171,5 +172,12 @@ namespace Elwig.Helpers { grid.Children.Add(tb); } + public static (int Year, int Week) GetYearAndWeek(this DateTime date) { + return (ISOWeek.GetYear(date), ISOWeek.GetWeekOfYear(date)); + } + + public static (int Year, int Tertial) GetYearAndTertial(this DateTime date) { + return (date.Month <= 7 ? date.Year - 1 : date.Year, date.Month == 12 || date.Month <= 3 ? 2 : date.Month <= 7 ? 3 : 1); + } } } diff --git a/Elwig/Helpers/Printing/Pdf.cs b/Elwig/Helpers/Printing/Pdf.cs index 994465b..5ab672a 100644 --- a/Elwig/Helpers/Printing/Pdf.cs +++ b/Elwig/Helpers/Printing/Pdf.cs @@ -1,20 +1,17 @@ using Elwig.Services; using Elwig.Windows; using System; -using System.Threading.Tasks; namespace Elwig.Helpers.Printing { public static class Pdf { - public static Task Init(Action? evtHandler = null) { + public static void Init(Action? evtHandler = null) { PdfiumNative.FPDF_InitLibrary(); evtHandler?.Invoke(); - return Task.CompletedTask; } - public static Task Cleanup() { + public static void Cleanup() { PdfiumNative.FPDF_DestroyLibrary(); - return Task.CompletedTask; } public static void Show(TempFile file, string title) { @@ -31,14 +28,13 @@ namespace Elwig.Helpers.Printing { }); } - public static Task Print(string path, int copies = 1, bool doublePaged = false) { + public static void Print(string path, int copies = 1, bool doublePaged = false) { try { var printer = new PdfPrinter(); printer.Print(path, copies, doublePaged); } catch (Exception exc) { InteractionService.ShowException("Fehler beim Drucken", "Beim Drucken ist ein Fehler aufgetreten", exc); } - return Task.CompletedTask; } } } diff --git a/Elwig/Services/BackupService.cs b/Elwig/Services/BackupService.cs new file mode 100644 index 0000000..68a3f63 --- /dev/null +++ b/Elwig/Services/BackupService.cs @@ -0,0 +1,110 @@ +using Elwig.Helpers; +using Elwig.Helpers.Export; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Elwig.Services { + public static class BackupService { + + public static bool IsAnyBackupUpToDate { + get { + var lastDbWrite = File.GetLastWriteTime(App.Config.DatabaseFile); + lastDbWrite += new TimeSpan(0, 0, -1); + return Directory.GetFiles(App.Config.BackupPath, "????-??-??_??-??-??.*.zip") + .Any(n => DateTime.ParseExact(Path.GetFileName(n)[..19], "yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture, DateTimeStyles.None) >= lastDbWrite); + } + } + + public static async Task TryDatabaseBackup() { + if (IsAnyBackupUpToDate) return; + await BackupDatabase(); + PruneBackups(); + } + + public static async Task BackupDatabase() { + await Database.ExportSql(Path.Combine(App.Config.BackupPath, $"{File.GetLastWriteTime(App.Config.DatabaseFile):yyyy-MM-dd_HH-mm-ss}.sql.zip"), true); + } + + public static void PruneBackups() { + var retain = new HashSet(); + var backups = Directory.GetFiles(App.Config.BackupPath, "????-??-??_??-??-??.*.zip") + .Select(n => (FileName: n, Timestamp: DateTime.ParseExact(Path.GetFileName(n)[..19], "yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture, DateTimeStyles.None))) + .OrderByDescending(t => t.Timestamp).ToArray(); + + DateTime now; + + // retain hourly backups + now = DateTime.Now; + for (int i = 0; i < App.Config.BackupRetainHours; i++, now = now.AddHours(-1)) { + foreach (var b in backups) { + if (b.Timestamp.Date == now.Date && b.Timestamp.Hour == now.Hour) { + retain.Add(b.FileName); + break; + } + } + } + + // retain daily backups + now = DateTime.Now; + for (int i = 0; i < App.Config.BackupRetainDays; i++, now = now.AddDays(-1)) { + foreach (var b in backups) { + if (b.Timestamp.Date == now.Date) { + retain.Add(b.FileName); + break; + } + } + } + + // retain weekly backups + now = DateTime.Now; + for (int i = 0; i < App.Config.BackupRetainWeeks; i++, now = now.AddDays(-7)) { + foreach (var b in backups) { + if (b.Timestamp.GetYearAndWeek() == now.GetYearAndWeek()) { + retain.Add(b.FileName); + break; + } + } + } + + // retain monthly backups + now = DateTime.Now; + for (int i = 0; i < App.Config.BackupRetainMonths; i++, now = now.AddMonths(-1)) { + foreach (var b in backups) { + if (b.Timestamp.Year == now.Year && b.Timestamp.Month == now.Month) { + retain.Add(b.FileName); + break; + } + } + } + + // retain tertial backups + now = new DateTime(DateTime.Now.Year + 1, 3, 1); + for (int i = 0; i < 600; i++, now = now.AddMonths(-4)) { + foreach (var b in backups) { + if (b.Timestamp.GetYearAndTertial() == now.GetYearAndTertial()) { + retain.Add(b.FileName); + break; + } + } + } + + // delete non-retained files + foreach (var b in backups) { + if (retain.Contains(b.FileName)) continue; + try { + File.Delete(b.FileName); + } catch { } + } + // delete temp files + foreach (var f in Directory.GetFiles(App.Config.BackupPath, "*.tmp")) { + try { + File.Delete(f); + } catch { } + } + } + } +} diff --git a/Elwig/Windows/MainWindow.xaml.cs b/Elwig/Windows/MainWindow.xaml.cs index 0ab9560..1c88408 100644 --- a/Elwig/Windows/MainWindow.xaml.cs +++ b/Elwig/Windows/MainWindow.xaml.cs @@ -50,22 +50,27 @@ namespace Elwig.Windows { _syncTimer.Start(); } - private void Window_Closing(object sender, CancelEventArgs evt) { + private async void Window_Closing(object sender, CancelEventArgs evt) { + evt.Cancel = !App.ForceShutdown; + if (App.NumWindows > 1 && !App.ForceShutdown) { - foreach (var w in App.Current.Windows.Cast().Where(w => ((w as AdministrationWindow)?.IsEditing ?? false) || ((w as AdministrationWindow)?.IsCreating ?? false))) { + foreach (var w in Application.Current.Windows.Cast().Where(w => ((w as AdministrationWindow)?.IsEditing ?? false) || ((w as AdministrationWindow)?.IsCreating ?? false))) { try { w.Close(); } catch { } } Thread.Sleep(100); - if (App.NumWindows > 1 && !App.Current.Windows.Cast().Any(w => ((w as AdministrationWindow)?.IsEditing ?? false) || ((w as AdministrationWindow)?.IsCreating ?? false))) { + if (App.NumWindows > 1 && !Application.Current.Windows.Cast().Any(w => ((w as AdministrationWindow)?.IsEditing ?? false) || ((w as AdministrationWindow)?.IsCreating ?? false))) { if (!InteractionService.AskConfirmation("Elwig beenden", "Es sind noch weitere Fenster geƶffnet.\nSollen alle Fenster geschlossen werden?")) { - evt.Cancel = true; - } else { - Application.Current.Shutdown(); + return; } + App.ForceShutdown = true; } } + + Mouse.OverrideCursor = Cursors.AppStarting; + await (Application.Current as App)!.OnClosing(); + Application.Current.Shutdown(); } private void Menu_Help_About_Click(object sender, RoutedEventArgs evt) { @@ -180,6 +185,8 @@ namespace Elwig.Windows { try { var d = new OpenFileDialog() { Title = "Datenbank wiederherstellen - Elwig", + DefaultDirectory = App.Config.BackupPath, + InitialDirectory = App.Config.BackupPath, DefaultExt = "sql.zip", Filter = "SQLite-Datenbank (*.sqlite3, *.sqlite3.zip, *.sql, *.sql.zip)|*.sqlite3;*.sqlite3.zip;*.sql;*.sql.zip", }; diff --git a/Installer/Files/README.txt b/Installer/Files/README.txt index fff54f8..18e28b0 100644 --- a/Installer/Files/README.txt +++ b/Installer/Files/README.txt @@ -13,5 +13,6 @@ Data and configuration folder C:\ProgramData\Elwig\ - config.ini : main configuration file - database.sqlite3 : stores all data +- backups\ : (automatic) backups of the database - imported.txt : list of all imported *.elwig.zip files to not automatically import them again - mails\ : sent/outgoing email log diff --git a/Installer/Files/config.ini b/Installer/Files/config.ini index 7fc584c..d808bf2 100644 --- a/Installer/Files/config.ini +++ b/Installer/Files/config.ini @@ -3,6 +3,7 @@ ; Only needed, if more than one branch is stored in database ;branch = Zweigstelle ;debug = true +;weighing = gross ; gross/net/box [database] ; Relative or absolute path to database file @@ -10,6 +11,14 @@ file = database.sqlite3 ; Enables database logging ;log = db.log +[backup] +;auto = false +;path = backups +;retain_hours = 36 +;retain_days = 8 +;retain_weeks = 6 +;retain_months = 18 + [update] url = https://elwig.at/files/elwig/latest auto = true diff --git a/Tests/UnitTests/DocumentTests/Setup.cs b/Tests/UnitTests/DocumentTests/Setup.cs index 070f6d2..ccac858 100644 --- a/Tests/UnitTests/DocumentTests/Setup.cs +++ b/Tests/UnitTests/DocumentTests/Setup.cs @@ -24,13 +24,13 @@ namespace Tests.UnitTests.DocumentTests { } [OneTimeSetUp] - public async Task SetupPrinting() { - await Pdf.Init(); + public void SetupPrinting() { + Pdf.Init(); } [OneTimeTearDown] - public async Task TeardownPrinting() { - await Pdf.Cleanup(); + public void TeardownPrinting() { + Pdf.Cleanup(); } } }