From 4ee50eeb202f2b0797b1e56e7a4bea1396618bc5 Mon Sep 17 00:00:00 2001 From: John Andrews Date: Mon, 17 Jun 2024 08:41:13 +1200 Subject: [PATCH] FF-1615: Move and Copy now use common method to get additional files --- BasicNodes/File/CopyFile.cs | 43 +++++------ BasicNodes/File/MoveFile.cs | 52 ++++--------- BasicNodes/Helpers/FileHelper.cs | 26 ------- BasicNodes/Helpers/FolderHelper.cs | 74 ++++++++++++++++++ BasicNodes/Tests/AdditionalFilesTests.cs | 49 ++++++++++++ BasicNodes/Tests/FileSizeCompareTests.cs | 8 +- BasicNodes/Tests/TestLogger.cs | 56 -------------- BasicNodes/Tests/_LocalFileService.cs | 20 ++--- BasicNodes/Tests/_TestBase.cs | 66 ++++++++++++++++ BasicNodes/Tests/_TestLogger.cs | 92 +++++++++++++++++++++++ FileFlows.Plugin.dll | Bin 142336 -> 142336 bytes FileFlows.Plugin.pdb | Bin 34680 -> 34676 bytes 12 files changed, 328 insertions(+), 158 deletions(-) delete mode 100644 BasicNodes/Helpers/FileHelper.cs create mode 100644 BasicNodes/Helpers/FolderHelper.cs create mode 100644 BasicNodes/Tests/AdditionalFilesTests.cs delete mode 100644 BasicNodes/Tests/TestLogger.cs create mode 100644 BasicNodes/Tests/_TestBase.cs create mode 100644 BasicNodes/Tests/_TestLogger.cs diff --git a/BasicNodes/File/CopyFile.cs b/BasicNodes/File/CopyFile.cs index 3d07a25e..78dbb0b4 100644 --- a/BasicNodes/File/CopyFile.cs +++ b/BasicNodes/File/CopyFile.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using FileFlows.BasicNodes.Helpers; using FileFlows.Plugin; using FileFlows.Plugin.Attributes; using FileFlows.Plugin.Helpers; @@ -151,35 +152,29 @@ public class CopyFile : Node if (AdditionalFiles?.Any() == true) { - try + string shortNameLookup = FileHelper.GetShortFileName(AdditionalFilesFromOriginal ? args.FileName : inputFile); + if (shortNameLookup.LastIndexOf(".", StringComparison.InvariantCulture) > 0) + shortNameLookup = shortNameLookup[..shortNameLookup.LastIndexOf(".", StringComparison.Ordinal)]; + + args.Logger?.ILog("Additional Files: " + string.Join(", ", AdditionalFiles)); + var addFiles = FolderHelper.GetAdditionalFiles(args.Logger, args.FileService, args.ReplaceVariables, + shortNameLookup, srcDir, AdditionalFiles); + + foreach (var addFile in addFiles) { - foreach (var additionalOrig in AdditionalFiles) + try { - string additional = args.ReplaceVariables(additionalOrig, stripMissing: true); - foreach(var addFile in args.FileService.GetFiles(srcDir, additional).ValueOrDefault ?? new string[] {}) - { - try - { - string shortName = FileHelper.GetShortFileName(addFile); - - string addFileDest = destDir + args.FileService.PathSeparator + shortName; - args.FileService.FileCopy(addFile, addFileDest, true); - //args.CopyFile(addFile, addFileDest, updateWorkingFile: false); + string shortName = FileHelper.GetShortFileName(addFile); - //FileHelper.ChangeOwner(args.Logger, addFileDest, file: true); + string addFileDest = destDir + args.FileService.PathSeparator + shortName; + args.FileService.FileCopy(addFile, addFileDest, true); - args.Logger?.ILog("Copied file: \"" + addFile + "\" to \"" + addFileDest + "\""); - } - catch (Exception ex) - { - args.Logger?.ILog("Failed copying file: \"" + addFile + "\": " + ex.Message); - } - } + args.Logger?.ILog("Copied file: \"" + addFile + "\" to \"" + addFileDest + "\""); + } + catch (Exception ex) + { + args.Logger?.ILog("Failed copying file: \"" + addFile + "\": " + ex.Message); } - } - catch (Exception ex) - { - args.Logger.WLog("Error copying additional files: " + ex.Message); } } diff --git a/BasicNodes/File/MoveFile.cs b/BasicNodes/File/MoveFile.cs index fe68a91b..37d3a6b2 100644 --- a/BasicNodes/File/MoveFile.cs +++ b/BasicNodes/File/MoveFile.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.RegularExpressions; +using FileFlows.BasicNodes.Helpers; using FileFlows.Plugin; using FileFlows.Plugin.Attributes; using FileFlows.Plugin.Helpers; @@ -156,49 +157,24 @@ public class MoveFile : Node if(AdditionalFiles?.Any() == true) { args.Logger?.ILog("Additional Files: " + string.Join(", ", AdditionalFiles)); + var addFiles = FolderHelper.GetAdditionalFiles(args.Logger, args.FileService, args.ReplaceVariables, + shortNameLookup, srcDir, AdditionalFiles); - try + string destDir = FileHelper.GetDirectory(dest); + foreach (var addFile in addFiles) { - string destDir = FileHelper.GetDirectory(dest); - args.FileService.DirectoryCreate(destDir); - - args.Logger?.ILog("Looking for additional files in directory: " + srcDir); - foreach (var additionalOrig in AdditionalFiles) + try { - string additional = args.ReplaceVariables(additionalOrig, stripMissing: true); - if (Regex.IsMatch(additionalOrig, @"\.[a-z0-9A-Z]+$") == false) - additional = "*" + additional; // add the leading start for the search - - args.Logger?.ILog("Looking for additional files: " + additional); - var srcDirFiles = args.FileService.GetFiles(srcDir, additional).ValueOrDefault ?? new string[] { }; - foreach(var addFile in srcDirFiles) - { - try - { - if (Regex.IsMatch(additional, @"\*\.[a-z0-9A-Z]+$")) - { - // make sure the file starts with same name - var addFileName = FileHelper.GetShortFileName(addFile); - if (addFileName.ToLowerInvariant().StartsWith(shortNameLookup.ToLowerInvariant()) == - false) - continue; - } - args.Logger?.ILog("Additional files: " + addFile); + args.Logger?.ILog("Additional files: " + addFile); - string addFileDest = destDir + args.FileService.PathSeparator + FileHelper.GetShortFileName(addFile); - args.FileService.FileMove(addFile, addFileDest, true); - args.Logger?.ILog("Moved file: \"" + addFile + "\" to \"" + addFileDest + "\""); - } - catch(Exception ex) - { - args.Logger?.ILog("Failed moving file: \"" + addFile + "\": " + ex.Message); - } - } + string addFileDest = FileHelper.Combine(destDir, FileHelper.GetShortFileName(addFile)); + args.FileService.FileMove(addFile, addFileDest, true); + args.Logger?.ILog("Moved file: \"" + addFile + "\" to \"" + addFileDest + "\""); + } + catch(Exception ex) + { + args.Logger?.ILog("Failed moving file: \"" + addFile + "\": " + ex.Message); } - } - catch(Exception ex) - { - args.Logger.WLog("Error moving additional files: " + ex.Message); } } else diff --git a/BasicNodes/Helpers/FileHelper.cs b/BasicNodes/Helpers/FileHelper.cs deleted file mode 100644 index 06167ea6..00000000 --- a/BasicNodes/Helpers/FileHelper.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Diagnostics; - -namespace FileFlows.BasicNodes.Helpers; - -/// -/// File Helper -/// -public static class FileHelper -{ - /// - /// Sets the last write time of a file - /// - /// the file path - /// the UTC to set - public static void SetLastWriteTime(string filePath, DateTime utcDate) - => System.IO.File.SetLastWriteTimeUtc(filePath, utcDate); - - /// - /// Sets the last write time of a file - /// - /// the file path - /// the UTC to set - public static void SetCreationTime(string filePath, DateTime utcDate) - => System.IO.File.SetCreationTimeUtc(filePath, utcDate); - -} \ No newline at end of file diff --git a/BasicNodes/Helpers/FolderHelper.cs b/BasicNodes/Helpers/FolderHelper.cs new file mode 100644 index 00000000..09cff5e4 --- /dev/null +++ b/BasicNodes/Helpers/FolderHelper.cs @@ -0,0 +1,74 @@ +using System.Text.RegularExpressions; +using FileFlows.Plugin; +using FileFlows.Plugin.Helpers; +using FileFlows.Plugin.Services; + +namespace FileFlows.BasicNodes.Helpers; + +/// +/// Folder Helper +/// +public static class FolderHelper +{ + /// + /// Gets additional files matching the criteria + /// + /// the logger to use + /// the file serverice to use + /// the function to replace variables in the patterns + /// the shortname of the source file to match against + /// the directory to search in + /// the patterns of the additional files + /// a list of additional files found + public static List GetAdditionalFiles(ILogger logger, IFileService fileService, + Func replaceVariables, string shortNameLookup, string directory, string[] patterns) + { + List results = new(); + if (string.IsNullOrWhiteSpace(directory) || patterns == null || patterns.Length < 1) + return results; + + logger?.ILog("Additional Files: " + string.Join(", ", patterns)); + + try + { + logger?.ILog("Looking for additional files in directory: " + directory); + foreach (var additionalOrig in patterns) + { + string additional = replaceVariables(additionalOrig, true, true); + if (Regex.IsMatch(additionalOrig, @"^\.[a-z0-9A-Z]+$")) + additional = "*" + additional; // add the leading start for the search + + logger?.ILog("Looking for additional files: " + additional); + var srcDirFiles = fileService.GetFiles(directory, additional).ValueOrDefault ?? new string[] { }; + foreach (var addFile in srcDirFiles) + { + try + { + if (Regex.IsMatch(additional, @"\*\.[a-z0-9A-Z]+$")) + { + // make sure the file starts with same name + var addFileName = FileHelper.GetShortFileName(addFile); + if (addFileName.ToLowerInvariant().StartsWith(shortNameLookup.ToLowerInvariant()) == + false) + continue; + } + + logger?.ILog("Additional files: " + addFile); + results.Add(addFile); + } + catch (Exception ex) + { + logger?.ILog("Failed moving file: \"" + addFile + "\": " + ex.Message); + } + } + } + } + catch (Exception ex) + { + logger.WLog("Error moving additional files: " + ex.Message); + } + + return results; + } + +} \ No newline at end of file diff --git a/BasicNodes/Tests/AdditionalFilesTests.cs b/BasicNodes/Tests/AdditionalFilesTests.cs new file mode 100644 index 00000000..2c519afe --- /dev/null +++ b/BasicNodes/Tests/AdditionalFilesTests.cs @@ -0,0 +1,49 @@ +#if(DEBUG) + +using System.IO; +using FileFlows.BasicNodes.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace BasicNodes.Tests; + +/// +/// Tests fot he additional files method +/// +[TestClass] +public class AdditionalFilesTests: TestBase +{ + [TestMethod] + public void Basic() + { + var fileService = new LocalFileService(); + var dir = Path.Combine(TempPath, Guid.NewGuid().ToString()); + Directory.CreateDirectory(dir); + CreateFile(dir, "my movie.mkv"); + CreateFile(dir, "my movie.srt"); + CreateFile(dir, "my movie.en.sub"); + CreateFile(dir, "my movie.it.srt"); + CreateFile(dir, "not my movie.en.sub"); + CreateFile(dir, "not my movie.sub"); + CreateFile(dir, "not my movie.srt"); + var results = FolderHelper.GetAdditionalFiles(Logger, fileService, + (s, b, b2) => s, + "my movie", dir, [".srt", ".sub"]); + Assert.AreEqual(3, results.Count); + Assert.IsTrue(results.Contains(Path.Combine(dir, "my movie.srt"))); + Assert.IsTrue(results.Contains(Path.Combine(dir, "my movie.en.sub"))); + Assert.IsTrue(results.Contains(Path.Combine(dir, "my movie.it.srt"))); + + } + + /// + /// Creates a file + /// + /// the directory to create the file in + /// the name of the file + private void CreateFile(string directory, string name) + { + System.IO.File.WriteAllText(Path.Combine(directory, name), ""); + } +} + +#endif \ No newline at end of file diff --git a/BasicNodes/Tests/FileSizeCompareTests.cs b/BasicNodes/Tests/FileSizeCompareTests.cs index d6a74ffb..8ec357c2 100644 --- a/BasicNodes/Tests/FileSizeCompareTests.cs +++ b/BasicNodes/Tests/FileSizeCompareTests.cs @@ -13,7 +13,7 @@ namespace BasicNodes.Tests private string CreateFile(int size) { string tempFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - File.WriteAllText(tempFile, new string('a', size)); + System.IO.File.WriteAllText(tempFile, new string('a', size)); return tempFile; } @@ -70,7 +70,7 @@ namespace BasicNodes.Tests var logger = new TestLogger(); var args = new FileFlows.Plugin.NodeParameters(tempFile, logger, false, string.Empty, null); Assert.IsTrue(args.WorkingFileSize > 0); - File.Delete(tempFile); + System.IO.File.Delete(tempFile); string wfFile = CreateFile(1); args.SetWorkingFile(wfFile); @@ -86,7 +86,7 @@ namespace BasicNodes.Tests string tempFile = CreateFile(2); var logger = new TestLogger(); var args = new FileFlows.Plugin.NodeParameters(tempFile, logger, false, string.Empty, null); - File.Delete(tempFile); + System.IO.File.Delete(tempFile); string wfFile = CreateFile(20); args.SetWorkingFile(wfFile); @@ -102,7 +102,7 @@ namespace BasicNodes.Tests string tempFile = CreateFile(2); var logger = new TestLogger(); var args = new FileFlows.Plugin.NodeParameters(tempFile, logger, false, string.Empty, null); - File.Delete(tempFile); + System.IO.File.Delete(tempFile); string wfFile = CreateFile(2); args.SetWorkingFile(wfFile); diff --git a/BasicNodes/Tests/TestLogger.cs b/BasicNodes/Tests/TestLogger.cs deleted file mode 100644 index c5bbe7d3..00000000 --- a/BasicNodes/Tests/TestLogger.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -#if (DEBUG) - -namespace BasicNodes.Tests -{ - using FileFlows.Plugin; - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text; - using System.Threading.Tasks; - - internal class TestLogger : ILogger - { - private List Messages = new List(); - - public void DLog(params object[] args) => Log("DBUG", args); - - public void ELog(params object[] args) => Log("ERRR", args); - - public void ILog(params object[] args) => Log("INFO", args); - - public void WLog(params object[] args) => Log("WARN", args); - - private void Log(string type, object[] args) - { - if (args == null || args.Length == 0) - return; -#pragma warning disable IL2026 - string message = type + " -> " + - string.Join(", ", args.Select(x => - x == null ? "null" : - x.GetType().IsPrimitive || x is string ? x.ToString() : - System.Text.Json.JsonSerializer.Serialize(x))); -#pragma warning restore IL2026 - Messages.Add(message); - } - - public bool Contains(string message) - { - if (string.IsNullOrWhiteSpace(message)) - return false; - - string log = string.Join(Environment.NewLine, Messages); - return log.Contains(message); - } - - public string GetTail(int length = 50) => "Not implemented"; - - public override string ToString() - => string.Join(Environment.NewLine, this.Messages.ToArray()); - } -} - -#endif \ No newline at end of file diff --git a/BasicNodes/Tests/_LocalFileService.cs b/BasicNodes/Tests/_LocalFileService.cs index 09398e33..9f310169 100644 --- a/BasicNodes/Tests/_LocalFileService.cs +++ b/BasicNodes/Tests/_LocalFileService.cs @@ -143,7 +143,7 @@ public class LocalFileService : IFileService return Result.Fail("Cannot access protected path: " + path); try { - return File.Exists(path); + return System.IO.File.Exists(path); } catch (Exception) { @@ -309,7 +309,7 @@ public class LocalFileService : IFileService return Result.Fail("Cannot access protected path: " + path); try { - File.AppendAllText(path, text); + System.IO.File.AppendAllText(path, text); SetPermissions(path); return true; } @@ -349,11 +349,11 @@ public class LocalFileService : IFileService try { - if (File.Exists(path)) - File.SetLastWriteTimeUtc(path, DateTime.UtcNow); + if (System.IO.File.Exists(path)) + System.IO.File.SetLastWriteTimeUtc(path, DateTime.UtcNow); else { - File.Create(path); + System.IO.File.Create(path); SetPermissions(path); } @@ -376,10 +376,10 @@ public class LocalFileService : IFileService return Result.Fail("Cannot access protected path: " + path); try { - if (!File.Exists(path)) + if (!System.IO.File.Exists(path)) return Result.Fail("File not found."); - File.SetCreationTimeUtc(path, date); + System.IO.File.SetCreationTimeUtc(path, date); return Result.Success(true); } catch (Exception ex) @@ -394,10 +394,10 @@ public class LocalFileService : IFileService return Result.Fail("Cannot access protected path: " + path); try { - if (!File.Exists(path)) + if (!System.IO.File.Exists(path)) return Result.Fail("File not found."); - File.SetLastWriteTimeUtc(path, date); + System.IO.File.SetLastWriteTimeUtc(path, date); return Result.Success(true); } catch (Exception ex) @@ -449,7 +449,7 @@ public class LocalFileService : IFileService permissions = 777; - if ((File.Exists(path) == false && Directory.Exists(path) == false)) + if ((System.IO.File.Exists(path) == false && Directory.Exists(path) == false)) { logMethod("SetPermissions: File doesnt existing, skipping"); return; diff --git a/BasicNodes/Tests/_TestBase.cs b/BasicNodes/Tests/_TestBase.cs new file mode 100644 index 00000000..2f749502 --- /dev/null +++ b/BasicNodes/Tests/_TestBase.cs @@ -0,0 +1,66 @@ +#if(DEBUG) + +using System.Runtime.InteropServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using FileFlows.BasicNodes; + +namespace BasicNodes.Tests; + +/// +/// Base class for the tests +/// +[TestClass] +public abstract class TestBase +{ + /// + /// The test context instance + /// + private TestContext testContextInstance; + + internal TestLogger Logger = new(); + + /// + /// Gets or sets the test context + /// + public TestContext TestContext + { + get => testContextInstance; + set => testContextInstance = value; + } + + public string TestPath { get; private set; } + public string TempPath { get; private set; } + + public readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + public readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + [TestInitialize] + public void TestInitialize() + { + Logger.Writer = (msg) => TestContext.WriteLine(msg); + + this.TestPath = this.TestPath?.EmptyAsNull() ?? (IsLinux ? "~/src/ff-files/test-files/videos" : @"d:\videos\testfiles"); + this.TempPath = this.TempPath?.EmptyAsNull() ?? (IsLinux ? "~/src/ff-files/temp" : @"d:\videos\temp"); + + this.TestPath = this.TestPath.Replace("~/", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/"); + this.TempPath = this.TempPath.Replace("~/", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/"); + + if (Directory.Exists(this.TempPath) == false) + Directory.CreateDirectory(this.TempPath); + } + + [TestCleanup] + public void CleanUp() + { + TestContext.WriteLine(Logger.ToString()); + } + + protected virtual void TestStarting() + { + + } + +} + +#endif \ No newline at end of file diff --git a/BasicNodes/Tests/_TestLogger.cs b/BasicNodes/Tests/_TestLogger.cs new file mode 100644 index 00000000..ac3079f0 --- /dev/null +++ b/BasicNodes/Tests/_TestLogger.cs @@ -0,0 +1,92 @@ +#if (DEBUG) + +namespace BasicNodes.Tests; + +using FileFlows.Plugin; + +/// +/// A logger for tests that stores the logs in memory +/// +public class TestLogger : ILogger +{ + private readonly List Messages = new(); + + /// + /// Writes an information log message + /// + /// the log parameters + public void ILog(params object[] args) + => Log(LogType.Info, args); + + /// + /// Writes an debug log message + /// + /// the log parameters + public void DLog(params object[] args) + => Log(LogType.Debug, args); + + /// + /// Writes an warning log message + /// + /// the log parameters + public void WLog(params object[] args) + => Log(LogType.Warning, args); + + /// + /// Writes an error log message + /// + /// the log parameters + public void ELog(params object[] args) + => Log(LogType.Error, args); + + /// + /// Gets the tail of the log + /// + /// the number of messages to get + public string GetTail(int length = 50) + { + if (Messages.Count <= length) + return string.Join(Environment.NewLine, Messages); + return string.Join(Environment.NewLine, Messages.TakeLast(50)); + } + + /// + /// Logs a message + /// + /// the type of log to record + /// the arguments of the message + private void Log(LogType type, params object[] args) + { + #pragma warning disable IL2026 + string message = type + " -> " + string.Join(", ", args.Select(x => + x == null ? "null" : + x.GetType().IsPrimitive ? x.ToString() : + x is string ? x.ToString() : + System.Text.Json.JsonSerializer.Serialize(x))); + #pragma warning restore IL2026 + Writer?.Invoke(message); + Messages.Add(message); + } + + /// + /// Gets or sets an optional writer + /// + public Action Writer { get; set; } + + /// + /// Returns the entire log as a string + /// + /// the entire log + public override string ToString() + => string.Join(Environment.NewLine, Messages); + + /// + /// Checks if the log contains the text + /// + /// the text to check for + /// true if it contains it, otherwise false + public bool Contains(string text) + => Messages.Any(x => x.Contains(text)); +} + +#endif \ No newline at end of file diff --git a/FileFlows.Plugin.dll b/FileFlows.Plugin.dll index 4552ba51651cdfd9ed094079bcbfb0883d0c2dae..ed56948a8832decaa0c75fc08248301e07634a5b 100644 GIT binary patch delta 250 zcmZp;!O?JoV?qbZBAtc%8+%%N7`OH?HSh|Y3u};`$(QHbT`oFDp7*5Tb{ReA(r7?`J-rx{tABpW9i7+V@ATN<0D8Jiex4_0KF$jGvB^`V~W zFO--91cbh&-7o%< diff --git a/FileFlows.Plugin.pdb b/FileFlows.Plugin.pdb index ebe22bbaf00230b61852350c4fef9efdbfe06610..1b517708981ec478b513b543f90a0280daf94d43 100644 GIT binary patch delta 2881 zcmZwIdsGuw9tZH>B!MJ=7@n0j5;TTXL;~TJAo2G5LffMf7ixEhZUB&Km>j>`7EyT3Jzyj5XT2-Q#zjj&VYI&l{Hs%KFUgn(S_>WiQV zbp*wN0T#jsums9sHGBm1uo*ss&*4jOLN5%m*HO@rT0u9hDEHtY{0uLEo1h>M@PR2H zfq%dSxD40f4*VN9a}*>55xfDxAcJs-h6G53OwdCC6hR4;fwdB44XlSv&;Z+@1@^!J z=maP9!2k@xFx-L>7=!1)NyLqVFT4rUAq_z2~nViRLFunD1$1fg1b`T%5SIRG!dI=5qR^ko_A5z`T>Ag~XXJ|@oEJ^OAcJs-h6KokLUBkX`y z*b9fi4o>KWGjJZhgKKab9>5q(uorhtI-4kCHu1q50zeE>2!p5$*Eup$WymNQv_&X6 z(5jVTQW=?$-vcuog)TS&rc4>7WXVW2Uq(vM!7|tmo$vy93uF`qsc;?=vhkF;RyqBW zEhqOJIe9}M1Vbo9KpZ527IaXMswR-__^Z zq;yIzC2p=OGZd7|p>T+X1W1KU&_e+fK?!^U);5$=a2t4eT$%y1K$*uQO`aR+p#VxX zZul>_Q4w+pl;ye8M%W6?&<9uGA-EabNet1j04z`oHaKYD(^=S<$EQmMft79>g!IVZ zK`(%7^uPze10Mhn@-TYRT!>J6;zQs`>BtMf01J&?RAZb(^+s>{6Ksc_&<6L^-gFRc zr*Sf!Gm2=~=tFmnzIZTSJeV(y8U2WB@*{tE%VhPZ>81dRfLKU01>%(mBpup((;HZU zDO73-qDqsP)|p((8%7sRVKfBa!;kP7CV= zBJyJMt^{wilR-$TbT68tWvDsB5BvTQ0F%IjZDqMUwv955Fnr>3u96q$S4K{5w9+cV zTSB9#d{X;qh({|#7Pi*2^#{}?A}-^qdr&PRE{8{pL{ijZk(A4$B9Vrz^{9(QKcJS0 zxI7*$6=~RNVe7a%m*kTI`CR&WQXHz=NEM=Oc79Q;ms5w=?d4U3h0=9L%7J- zYG!hdc5Hq#dt1zv)rsZZWf%9pTowB@_hsfcC)YmhsJ-xU!3fr>!*-*rFH&^+#(xXN zNh`eGo0m7|bdRuMu2=fv+9|c&GcLz9hDx*KFGqJcXAWGsuTkhHuHUIj?w*l#(zvCG z_weXg`7sE&t`6Q`zL4Iu>Y&|%5(K+8zPG@)ZC)ZRRdzlft+xdk$`E-M;8wg@I3+j`0tt^{Y0YASQ-UGB*E|58f`Z#7A1xQHvL;V=Rl! zCFT;fIjST+K5mg&ty0BU%u$i%MbYZGXnRXadT8Jg#^GQbeT?H8RYbn$9k*#=1^J~X;OKidq@B6)Gtev;iz{p;6fa#Vn&QQjg#5ju>XF22C zWUpOTDV%h~H5KlzOM;uSKd%~R7>WJy>UWr+*Ifj|yCrsW&F=*~9PYDUw+gsIS7Lg| L7-Ao-+r;}Hrl;+E delta 2896 zcmZwIdsGuw9tZH>B!PqgngSwki4X$_Bqo6nL=6O@DB+dxTJ@m36{&1}9~XyJDY~+X zqGDSVAFX<A?mIp#YXaC9H!@Py=<)0B^$)=z)uHl|PS)`V1=iH;XU?Bk(i41cz7^xqv(PfE@k- z|Ag;g5bnYw5Y1GPGq`~_1cDMGAqwKa1oI&Sav>i|paR&{2peHDY=?SighTKioQ8Jj zfnN9mu0lWDhKDc){{>MTE*w0-4}u{abPxxLkODSX1VwQwmR6ux4S#~IPzQUV3EqX% z&<;J&3tzxh=!Ylp0)(@$3g8aD5Cqdf3p2q43&08$um-B&_AFLK(%I+;X;2PTPy>I3 zeQ*%ogUUH7s)a+KFsdjXtZ*D$4;5y%5Lj$Pqz%Y!#3lJn~ z$O%{(!ej^l1%xNQn($TDkQVLp`F0Iupf3BZeh2lp;GCqTKu|&?L_r*6LLMxEQg|KK zKs_`iMbpWo=pc$Wa}!0;QgalQL7yRtR`P8+T5Hx(l^O5px`q))CpMh6n!{-a?18u7 zFr0u^I0rp&5k7}2a2*EV4h+K>yx>pHO&URhxe??9GMEejpn!1D&gJ?jD0Z%b;$hxm zgfuv8R$x*EWuxZWir_4K2p1rGo`MpR6{MW6ARVN`3OE4m@DdysC@36Ea0TL0aF^+< zlE|VY3CO?;0wDy{5CcY-2kDS&;jU0gOHh|VC9H>P*ar1*01m@RI0Ie$m=L$DA4=d&V2>kQf;-@tA*5*#0lEwkC1p5J2INAS z$pO!T1LdPGfr<=A+6KE|KlH*47y)snBLzSdSfCiHU>~$(is>uZmLaBVnG%-nWIEA* zGNtqq9IR4&0HpW;NXfv#;oq-U~?x=@VBuZ6l|MBYS6=Mnw3KdvLjy`V!WK;W#J=(?=I}<;~w6JmT}`-1}IomWF`^7uB-Q243X& zZOkmH=*)i5Bw3{%jrZ#NMm#FFL{uVY^snUM=;m1{Tx)QIP$#Fg^ zd6YancE(uqW=zqWqh!va(HHBa6*#L z;deVE2^keGnPS8K+Y8Esi8HQCLq8T|E);KmytK8qxi_F_q~6e<<$JKP?@7w$8`7)` z-E9-zANqU5B6;I^*CW?-k{`C8%l+}}hcnmJ+Vck%hpz<K94jL+$56T{f7JFP89!lAez383gInKM;!PZNi z-&x{PXB2xMoAGsC(`el;WvHdBDa9=|xpl*yow22heZE|N|GC#)bIkUt>X!$LKWb0= ztog+Ej~ag}j0oPM5Ayr!*HbE&&@kJClVjc&1D`oEI;KHpEC|uW=%dvdU9ql6UsR|r zDJW9wOY|iggRW3psMZ>^g$8X@iB>nm{{|C!h;?V#A