diff --git a/AaxDecrypter/AaxDecrypter.csproj b/AaxDecrypter/AaxDecrypter.csproj new file mode 100644 index 00000000..cdcc31dc --- /dev/null +++ b/AaxDecrypter/AaxDecrypter.csproj @@ -0,0 +1,123 @@ + + + + netstandard2.1 + + + + + lib\taglib-sharp.dll + + + + + + + + + + + + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + + diff --git a/AaxDecrypter/BytesCrackerLib/alglib1.dll b/AaxDecrypter/BytesCrackerLib/alglib1.dll new file mode 100644 index 00000000..6da4ca52 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/alglib1.dll differ diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_0_10000x789935_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_0_10000x789935_0.rtc new file mode 100644 index 00000000..6359b7a3 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_0_10000x789935_0.rtc differ diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_1_10000x791425_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_1_10000x791425_0.rtc new file mode 100644 index 00000000..57aa2b11 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_1_10000x791425_0.rtc differ diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_2_10000x790991_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_2_10000x790991_0.rtc new file mode 100644 index 00000000..0ab3c43a Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_2_10000x790991_0.rtc differ diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_3_10000x792120_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_3_10000x792120_0.rtc new file mode 100644 index 00000000..8e0438c8 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_3_10000x792120_0.rtc differ diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_4_10000x790743_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_4_10000x790743_0.rtc new file mode 100644 index 00000000..1e169c3e Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_4_10000x790743_0.rtc differ diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_5_10000x790568_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_5_10000x790568_0.rtc new file mode 100644 index 00000000..77c2a193 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_5_10000x790568_0.rtc differ diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_6_10000x791458_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_6_10000x791458_0.rtc new file mode 100644 index 00000000..4f7b21c9 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_6_10000x791458_0.rtc differ diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_7_10000x791707_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_7_10000x791707_0.rtc new file mode 100644 index 00000000..f4b7b061 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_7_10000x791707_0.rtc differ diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_8_10000x790202_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_8_10000x790202_0.rtc new file mode 100644 index 00000000..e279ee04 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_8_10000x790202_0.rtc differ diff --git a/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_9_10000x791022_0.rtc b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_9_10000x791022_0.rtc new file mode 100644 index 00000000..18e558f2 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/audible_byte#4-4_9_10000x791022_0.rtc differ diff --git a/AaxDecrypter/BytesCrackerLib/ffmpeg.exe b/AaxDecrypter/BytesCrackerLib/ffmpeg.exe new file mode 100644 index 00000000..57ceafbc Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/ffmpeg.exe differ diff --git a/AaxDecrypter/BytesCrackerLib/ffprobe.exe b/AaxDecrypter/BytesCrackerLib/ffprobe.exe new file mode 100644 index 00000000..42721143 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/ffprobe.exe differ diff --git a/AaxDecrypter/BytesCrackerLib/rcrack.exe b/AaxDecrypter/BytesCrackerLib/rcrack.exe new file mode 100644 index 00000000..a67724e5 Binary files /dev/null and b/AaxDecrypter/BytesCrackerLib/rcrack.exe differ diff --git a/AaxDecrypter/DecryptLib/AtomicParsley.exe b/AaxDecrypter/DecryptLib/AtomicParsley.exe new file mode 100644 index 00000000..fd840692 Binary files /dev/null and b/AaxDecrypter/DecryptLib/AtomicParsley.exe differ diff --git a/AaxDecrypter/DecryptLib/avcodec-57.dll b/AaxDecrypter/DecryptLib/avcodec-57.dll new file mode 100644 index 00000000..4efd87aa Binary files /dev/null and b/AaxDecrypter/DecryptLib/avcodec-57.dll differ diff --git a/AaxDecrypter/DecryptLib/avdevice-57.dll b/AaxDecrypter/DecryptLib/avdevice-57.dll new file mode 100644 index 00000000..3b91e4bc Binary files /dev/null and b/AaxDecrypter/DecryptLib/avdevice-57.dll differ diff --git a/AaxDecrypter/DecryptLib/avfilter-6.dll b/AaxDecrypter/DecryptLib/avfilter-6.dll new file mode 100644 index 00000000..7327e65f Binary files /dev/null and b/AaxDecrypter/DecryptLib/avfilter-6.dll differ diff --git a/AaxDecrypter/DecryptLib/avformat-57.dll b/AaxDecrypter/DecryptLib/avformat-57.dll new file mode 100644 index 00000000..a5c286f0 Binary files /dev/null and b/AaxDecrypter/DecryptLib/avformat-57.dll differ diff --git a/AaxDecrypter/DecryptLib/avutil-55.dll b/AaxDecrypter/DecryptLib/avutil-55.dll new file mode 100644 index 00000000..9805d769 Binary files /dev/null and b/AaxDecrypter/DecryptLib/avutil-55.dll differ diff --git a/AaxDecrypter/DecryptLib/cygcrypto-1.0.0.dll b/AaxDecrypter/DecryptLib/cygcrypto-1.0.0.dll new file mode 100644 index 00000000..7a01721d Binary files /dev/null and b/AaxDecrypter/DecryptLib/cygcrypto-1.0.0.dll differ diff --git a/AaxDecrypter/DecryptLib/cyggcc_s-1.dll b/AaxDecrypter/DecryptLib/cyggcc_s-1.dll new file mode 100644 index 00000000..732e75a4 Binary files /dev/null and b/AaxDecrypter/DecryptLib/cyggcc_s-1.dll differ diff --git a/AaxDecrypter/DecryptLib/cygmp4v2-2.dll b/AaxDecrypter/DecryptLib/cygmp4v2-2.dll new file mode 100644 index 00000000..1d851fed Binary files /dev/null and b/AaxDecrypter/DecryptLib/cygmp4v2-2.dll differ diff --git a/AaxDecrypter/DecryptLib/cygstdc++-6.dll b/AaxDecrypter/DecryptLib/cygstdc++-6.dll new file mode 100644 index 00000000..9d4468dc Binary files /dev/null and b/AaxDecrypter/DecryptLib/cygstdc++-6.dll differ diff --git a/AaxDecrypter/DecryptLib/cygwin1.dll b/AaxDecrypter/DecryptLib/cygwin1.dll new file mode 100644 index 00000000..c96cd624 Binary files /dev/null and b/AaxDecrypter/DecryptLib/cygwin1.dll differ diff --git a/AaxDecrypter/DecryptLib/cygz.dll b/AaxDecrypter/DecryptLib/cygz.dll new file mode 100644 index 00000000..874be5ec Binary files /dev/null and b/AaxDecrypter/DecryptLib/cygz.dll differ diff --git a/AaxDecrypter/DecryptLib/ffmpeg.exe b/AaxDecrypter/DecryptLib/ffmpeg.exe new file mode 100644 index 00000000..9d759ebc Binary files /dev/null and b/AaxDecrypter/DecryptLib/ffmpeg.exe differ diff --git a/AaxDecrypter/DecryptLib/ffprobe.exe b/AaxDecrypter/DecryptLib/ffprobe.exe new file mode 100644 index 00000000..2863d735 Binary files /dev/null and b/AaxDecrypter/DecryptLib/ffprobe.exe differ diff --git a/AaxDecrypter/DecryptLib/mp4trackdump.exe b/AaxDecrypter/DecryptLib/mp4trackdump.exe new file mode 100644 index 00000000..98abb027 Binary files /dev/null and b/AaxDecrypter/DecryptLib/mp4trackdump.exe differ diff --git a/AaxDecrypter/DecryptLib/postproc-54.dll b/AaxDecrypter/DecryptLib/postproc-54.dll new file mode 100644 index 00000000..5ec9448e Binary files /dev/null and b/AaxDecrypter/DecryptLib/postproc-54.dll differ diff --git a/AaxDecrypter/DecryptLib/swresample-2.dll b/AaxDecrypter/DecryptLib/swresample-2.dll new file mode 100644 index 00000000..591f8414 Binary files /dev/null and b/AaxDecrypter/DecryptLib/swresample-2.dll differ diff --git a/AaxDecrypter/DecryptLib/swscale-4.dll b/AaxDecrypter/DecryptLib/swscale-4.dll new file mode 100644 index 00000000..ec30e0bf Binary files /dev/null and b/AaxDecrypter/DecryptLib/swscale-4.dll differ diff --git a/AaxDecrypter/DecryptLib/taglib-sharp.dll b/AaxDecrypter/DecryptLib/taglib-sharp.dll new file mode 100644 index 00000000..87ec4bf1 Binary files /dev/null and b/AaxDecrypter/DecryptLib/taglib-sharp.dll differ diff --git a/AaxDecrypter/UNTESTED/AaxToM4bConverter.cs b/AaxDecrypter/UNTESTED/AaxToM4bConverter.cs new file mode 100644 index 00000000..39458e9d --- /dev/null +++ b/AaxDecrypter/UNTESTED/AaxToM4bConverter.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dinah.Core; +using Dinah.Core.Diagnostics; +using Dinah.Core.IO; +using Dinah.Core.StepRunner; + +namespace AaxDecrypter +{ + public interface ISimpleAaxToM4bConverter + { + event EventHandler DecryptProgressUpdate; + + bool Run(); + + string AppName { get; set; } + string inputFileName { get; } + byte[] coverBytes { get; } + string outDir { get; } + string outputFileName { get; } + + Chapters chapters { get; } + Tags tags { get; } + EncodingInfo encodingInfo { get; } + + void SetOutputFilename(string outFileName); + } + public interface IAdvancedAaxToM4bConverter : ISimpleAaxToM4bConverter + { + bool Step1_CreateDir(); + bool Step2_DecryptAax(); + bool Step3_Chapterize(); + bool Step4_InsertCoverArt(); + bool Step5_Cleanup(); + bool Step6_AddTags(); + bool End_CreateCue(); + bool End_CreateNfo(); + } + /// full c# app. integrated logging. no UI + public class AaxToM4bConverter : IAdvancedAaxToM4bConverter + { + public event EventHandler DecryptProgressUpdate; + + public string inputFileName { get; } + public string decryptKey { get; private set; } + + private StepSequence steps { get; } + public byte[] coverBytes { get; private set; } + + public string AppName { get; set; } = nameof(AaxToM4bConverter); + + public string outDir { get; private set; } + public string outputFileName { get; private set; } + + public Chapters chapters { get; private set; } + public Tags tags { get; private set; } + public EncodingInfo encodingInfo { get; private set; } + + public static async Task CreateAsync(string inputFile, string decryptKey) + { + var converter = new AaxToM4bConverter(inputFile, decryptKey); + await converter.prelimProcessing(); + converter.printPrelim(); + + return converter; + } + private AaxToM4bConverter(string inputFile, string decryptKey) + { + if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentNullException(nameof(inputFile), "Input file may not be null or whitespace"); + if (!File.Exists(inputFile)) throw new ArgumentNullException(nameof(inputFile), "File does not exist"); + + steps = new StepSequence + { + Name = "Convert Aax To M4b", + + ["Step 1: Create Dir"] = Step1_CreateDir, + ["Step 2: Decrypt Aax"] = Step2_DecryptAax, + ["Step 3: Chapterize and tag"] = Step3_Chapterize, + ["Step 4: Insert Cover Art"] = Step4_InsertCoverArt, + ["Step 5: Cleanup"] = Step5_Cleanup, + ["Step 6: Add Tags"] = Step6_AddTags, + ["End: Create Cue"] = End_CreateCue, + ["End: Create Nfo"] = End_CreateNfo + }; + + this.inputFileName = inputFile; + this.decryptKey = decryptKey; + } + + private async Task prelimProcessing() + { + this.tags = new Tags(this.inputFileName); + this.encodingInfo = new EncodingInfo(this.inputFileName); + this.chapters = new Chapters(this.inputFileName, this.tags.duration.TotalSeconds); + + var defaultFilename = Path.Combine( + Path.GetDirectoryName(this.inputFileName), + getASCIITag(this.tags.author), + getASCIITag(this.tags.title) + ".m4b" + ); + SetOutputFilename(defaultFilename); + + await Task.Run(() => saveCover(inputFileName)); + } + private string getASCIITag(string property) + { + foreach (char ch in new string(Path.GetInvalidFileNameChars()) + new string(Path.GetInvalidPathChars())) + property = property.Replace(ch.ToString(), ""); + return property; + } + + private void saveCover(string aaxFile) + { + using (var file = TagLib.File.Create(aaxFile, "audio/mp4", TagLib.ReadStyle.Average)) + this.coverBytes = file.Tag.Pictures[0].Data.Data; + } + + private void printPrelim() + { + Console.WriteLine("Audible Book ID = " + tags.id); + + Console.WriteLine("Book: " + tags.title); + Console.WriteLine("Author: " + tags.author); + Console.WriteLine("Narrator: " + tags.narrator); + Console.WriteLine("Year: " + tags.year); + Console.WriteLine("Total Time: " + + tags.duration.GetTotalTimeFormatted() + + " in " + chapters.Count() + " chapters"); + Console.WriteLine("WARNING-Source is " + + encodingInfo.originalBitrate + " kbits @ " + + encodingInfo.sampleRate + "Hz, " + + encodingInfo.channels + " channels"); + } + + public bool Run() + { + var (IsSuccess, Elapsed) = steps.Run(); + + if (!IsSuccess) + { + Console.WriteLine("WARNING-Conversion failed"); + return false; + } + + var speedup = (int)(tags.duration.TotalSeconds / (long)Elapsed.TotalSeconds); + Console.WriteLine("Speedup is " + speedup + "x realtime."); + Console.WriteLine("Done"); + return true; + } + + public void SetOutputFilename(string outFileName) + { + this.outputFileName = outFileName; + + if (Path.GetExtension(this.outputFileName) != ".m4b") + this.outputFileName = outputFileWithNewExt(".m4b"); + + this.outDir = Path.GetDirectoryName(this.outputFileName); + } + + private string outputFileWithNewExt(string extension) + => Path.Combine(this.outDir, Path.GetFileNameWithoutExtension(this.outputFileName) + '.' + extension.Trim('.')); + + public bool Step1_CreateDir() + { + ProcessRunner.WorkingDir = this.outDir; + Directory.CreateDirectory(this.outDir); + return true; + } + + public bool Step2_DecryptAax() + { + DecryptProgressUpdate?.Invoke(this, 0); + + var tempRipFile = Path.Combine(this.outDir, "funny.aac"); + + var fail = "WARNING-Decrypt failure. "; + + int returnCode; + if (string.IsNullOrWhiteSpace(decryptKey)) + { + returnCode = getKey_decrypt(tempRipFile); + } + else + { + returnCode = decrypt(tempRipFile); + if (returnCode == -99) + { + Console.WriteLine($"{fail}Incorrect decrypt key: {decryptKey}"); + this.decryptKey = null; + returnCode = getKey_decrypt(tempRipFile); + } + } + + if (returnCode == 100) + Console.WriteLine($"{fail}Thread completed without changing return code. This shouldn't be possible"); + else if (returnCode == 0) + { + // success! + FileExt.SafeMove(tempRipFile, outputFileWithNewExt(".mp4")); + DecryptProgressUpdate?.Invoke(this, 100); + return true; + } + else if (returnCode == -99) + Console.WriteLine($"{fail}Incorrect decrypt key: {decryptKey}"); + else // any other returnCode + Console.WriteLine($"{fail}Unknown failure code: {returnCode}"); + + FileExt.SafeDelete(tempRipFile); + DecryptProgressUpdate?.Invoke(this, 0); + return false; + } + + private int getKey_decrypt(string tempRipFile) + { + getKey(); + return decrypt(tempRipFile); + } + private void getKey() + { + Console.WriteLine("Discovering decrypt key"); + + Console.WriteLine("Getting file hash"); + var checksum = BytesCracker.GetChecksum(inputFileName); + Console.WriteLine("File hash calculated: " + checksum); + + Console.WriteLine("Cracking activation bytes"); + var activation_bytes = BytesCracker.GetActivationBytes(checksum); + this.decryptKey = activation_bytes; + Console.WriteLine("Activation bytes cracked. Decrypt key: " + activation_bytes); + } + + private int decrypt(string tempRipFile) + { + FileExt.SafeDelete(tempRipFile); + + Console.WriteLine("Decrypting with key " + decryptKey); + + var returnCode = 100; + var thread = new Thread(() => returnCode = this.ngDecrypt()); + thread.Start(); + + double fileLen = new FileInfo(this.inputFileName).Length; + while (thread.IsAlive && returnCode == 100) + { + Thread.Sleep(500); + if (File.Exists(tempRipFile)) + { + double tempLen = new FileInfo(tempRipFile).Length; + var percentProgress = tempLen / fileLen * 100.0; + DecryptProgressUpdate?.Invoke(this, (int)percentProgress); + } + } + + return returnCode; + } + + private int ngDecrypt() + { + var info = new ProcessStartInfo + { + FileName = DecryptSupportLibraries.mp4trackdumpPath, + Arguments = "-c " + this.encodingInfo.channels + " -r " + this.encodingInfo.sampleRate + " \"" + this.inputFileName + "\"" + }; + info.EnvironmentVariables["VARIABLE"] = decryptKey; + + var (output, exitCode) = info.RunHidden(); + + // bad checksum -- bad decrypt key + if (output.Contains("checksums mismatch, aborting!")) + return -99; + + return exitCode; + } + + // temp file names for steps 3, 4, 5 + string tempChapsPath => Path.Combine(this.outDir, "tempChaps.mp4"); + string mp4_file => outputFileWithNewExt(".mp4"); + string ff_txt_file => mp4_file + ".ff.txt"; + + public bool Step3_Chapterize() + { + string str1 = ""; + if (this.chapters.FirstChapterStart != 0.0) + { + str1 = " -ss " + this.chapters.FirstChapterStart.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + (this.chapters.LastChapterStart - 1.0).ToString("0.000", CultureInfo.InvariantCulture) + " "; + } + + string ffmpegTags = this.tags.GenerateFfmpegTags(); + string ffmpegChapters = this.chapters.GenerateFfmpegChapters(); + File.WriteAllText(ff_txt_file, ffmpegTags + ffmpegChapters); + + var tagAndChapterInfo = new ProcessStartInfo + { + FileName = DecryptSupportLibraries.ffmpegPath, + Arguments = "-y -i \"" + mp4_file + "\" -f ffmetadata -i \"" + ff_txt_file + "\" -map_metadata 1 -bsf:a aac_adtstoasc -c:a copy" + str1 + " -map 0 \"" + tempChapsPath + "\"" + }; + tagAndChapterInfo.RunHidden(); + + return true; + } + + public bool Step4_InsertCoverArt() + { + // save cover image as temp file + var coverPath = Path.Combine(this.outDir, "cover-" + Guid.NewGuid() + ".jpg"); + FileExt.CreateFile(coverPath, this.coverBytes); + + var insertCoverArtInfo = new ProcessStartInfo + { + FileName = DecryptSupportLibraries.atomicParsleyPath, + Arguments = "\"" + tempChapsPath + "\" --encodingTool \"" + AppName + "\" --artwork \"" + coverPath + "\" --overWrite" + }; + insertCoverArtInfo.RunHidden(); + + // delete temp file + FileExt.SafeDelete(coverPath); + + return true; + } + + public bool Step5_Cleanup() + { + FileExt.SafeDelete(mp4_file); + FileExt.SafeDelete(ff_txt_file); + FileExt.SafeMove(tempChapsPath, this.outputFileName); + + return true; + } + + public bool Step6_AddTags() + { + this.tags.AddAppleTags(this.outputFileName); + return true; + } + + public bool End_CreateCue() + { + File.WriteAllText(outputFileWithNewExt(".cue"), this.chapters.GetCuefromChapters(Path.GetFileName(this.outputFileName))); + return true; + } + + public bool End_CreateNfo() + { + File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateNfoContents(AppName, this.tags, this.encodingInfo, this.chapters)); + return true; + } + } +} diff --git a/AaxDecrypter/UNTESTED/BytesCracker.cs b/AaxDecrypter/UNTESTED/BytesCracker.cs new file mode 100644 index 00000000..24cffbac --- /dev/null +++ b/AaxDecrypter/UNTESTED/BytesCracker.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Dinah.Core; +using Dinah.Core.Diagnostics; + +namespace AaxDecrypter +{ + public static class BytesCracker + { + public static string GetChecksum(string aaxPath) + { + var info = new ProcessStartInfo + { + FileName = BytesCrackerSupportLibraries.ffprobePath, + Arguments = aaxPath.SurroundWithQuotes(), + WorkingDirectory = Directory.GetCurrentDirectory() + }; + + // checksum is in the debug info. ffprobe's debug info is written to stderr, not stdout + var readErrorOutput = true; + var ffprobeStderr = info.RunHidden(readErrorOutput).Output; + + // example checksum line: + // ... [aax] file checksum == 0c527840c4f18517157eb0b4f9d6f9317ce60cd1 + var checksum = ffprobeStderr.ExtractString("file checksum == ", 40); + + return checksum; + } + + /// use checksum to get activation bytes. activation bytes are unique per audible customer. only have to do this 1x/customer + public static string GetActivationBytes(string checksum) + { + var info = new ProcessStartInfo + { + FileName = BytesCrackerSupportLibraries.rcrackPath, + Arguments = @". -h " + checksum, + WorkingDirectory = Directory.GetCurrentDirectory() + }; + + var rcrackStdout = info.RunHidden().Output; + + // example result + // 0c527840c4f18517157eb0b4f9d6f9317ce60cd1 \xbd\x89X\x09 hex:bd895809 + var activation_bytes = rcrackStdout.ExtractString("hex:", 8); + + return activation_bytes; + } + } +} diff --git a/AaxDecrypter/UNTESTED/BytesCrackerSupportLibraries.cs b/AaxDecrypter/UNTESTED/BytesCrackerSupportLibraries.cs new file mode 100644 index 00000000..256b97cb --- /dev/null +++ b/AaxDecrypter/UNTESTED/BytesCrackerSupportLibraries.cs @@ -0,0 +1,28 @@ +using System.IO; + +namespace AaxDecrypter +{ + public static class BytesCrackerSupportLibraries + { + // GetActivationBytes dependencies + // rcrack.exe + // alglib1.dll + // RainbowCrack files to recover your own Audible activation data (activation_bytes) in an offline manner + // audible_byte#4-4_0_10000x789935_0.rtc + // audible_byte#4-4_1_10000x791425_0.rtc + // audible_byte#4-4_2_10000x790991_0.rtc + // audible_byte#4-4_3_10000x792120_0.rtc + // audible_byte#4-4_4_10000x790743_0.rtc + // audible_byte#4-4_5_10000x790568_0.rtc + // audible_byte#4-4_6_10000x791458_0.rtc + // audible_byte#4-4_7_10000x791707_0.rtc + // audible_byte#4-4_8_10000x790202_0.rtc + // audible_byte#4-4_9_10000x791022_0.rtc + + private static string appPath_ { get; } = Path.GetDirectoryName(Dinah.Core.Exe.FileLocationOnDisk); + private static string bytesCrackerLib_ { get; } = Path.Combine(appPath_, "BytesCrackerLib"); + + public static string ffprobePath { get; } = Path.Combine(bytesCrackerLib_, "ffprobe.exe"); + public static string rcrackPath { get; } = Path.Combine(bytesCrackerLib_, "rcrack.exe"); + } +} diff --git a/AaxDecrypter/UNTESTED/Chapters.cs b/AaxDecrypter/UNTESTED/Chapters.cs new file mode 100644 index 00000000..e92f563e --- /dev/null +++ b/AaxDecrypter/UNTESTED/Chapters.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Text; +using Dinah.Core.Diagnostics; + +namespace AaxDecrypter +{ + public class Chapters + { + private List markers { get; } + + public double FirstChapterStart => markers[0]; + public double LastChapterStart => markers[markers.Count - 1]; + + public Chapters(string file, double totalTime) + { + this.markers = getAAXChapters(file); + + // add end time + this.markers.Add(totalTime); + } + + private static List getAAXChapters(string file) + { + var info = new ProcessStartInfo + { + FileName = DecryptSupportLibraries.ffprobePath, + Arguments = "-loglevel panic -show_chapters -print_format xml \"" + file + "\"" + }; + var xml = info.RunHidden().Output; + + var xmlDocument = new System.Xml.XmlDocument(); + xmlDocument.LoadXml(xml); + var chapters = xmlDocument.SelectNodes("/ffprobe/chapters/chapter") + .Cast() + .Select(xmlNode => double.Parse(xmlNode.Attributes["start_time"].Value.Replace(",", "."), CultureInfo.InvariantCulture)) + .ToList(); + return chapters; + } + + // subtract 1 b/c end time marker is a real entry but isn't a real chapter + public int Count() => this.markers.Count - 1; + + public string GetCuefromChapters(string fileName) + { + var stringBuilder = new StringBuilder(); + if (fileName != "") + { + stringBuilder.Append("FILE \"" + fileName + "\" MP4\n"); + } + + for (var i = 0; i < Count(); i++) + { + var chapter = i + 1; + + var timeSpan = TimeSpan.FromSeconds(this.markers[i]); + var minutes = Math.Floor(timeSpan.TotalMinutes).ToString(); + var seconds = timeSpan.Seconds.ToString("D2"); + var milliseconds = (timeSpan.Milliseconds / 10).ToString("D2"); + string str = minutes + ":" + seconds + ":" + milliseconds; + + stringBuilder.Append("TRACK " + chapter + " AUDIO\n"); + stringBuilder.Append(" TITLE \"Chapter " + chapter.ToString("D2") + "\"\n"); + stringBuilder.Append(" INDEX 01 " + str + "\n"); + } + + return stringBuilder.ToString(); + } + + public string GenerateFfmpegChapters() + { + var stringBuilder = new StringBuilder(); + + for (var i = 0; i < Count(); i++) + { + var chapter = i + 1; + + var start = this.markers[i] * 1000.0; + var end = this.markers[i + 1] * 1000.0; + var chapterName = chapter.ToString("D3"); + + stringBuilder.Append("[CHAPTER]\n"); + stringBuilder.Append("TIMEBASE=1/1000\n"); + stringBuilder.Append("START=" + start + "\n"); + stringBuilder.Append("END=" + end + "\n"); + stringBuilder.Append("title=" + chapterName + "\n"); + } + + return stringBuilder.ToString(); + } + } +} diff --git a/AaxDecrypter/UNTESTED/DecryptSupportLibraries.cs b/AaxDecrypter/UNTESTED/DecryptSupportLibraries.cs new file mode 100644 index 00000000..66238676 --- /dev/null +++ b/AaxDecrypter/UNTESTED/DecryptSupportLibraries.cs @@ -0,0 +1,21 @@ +using System.IO; + +namespace AaxDecrypter +{ + public static class DecryptSupportLibraries + { + // OTHER EXTERNAL DEPENDENCIES + // ffprobe has these pre-req.s as I'm using it: + // avcodec-57.dll, avdevice-57.dll, avfilter-6.dll, avformat-57.dll, avutil-55.dll, postproc-54.dll, swresample-2.dll, swscale-4.dll, taglib-sharp.dll + // + // something else needs the cygwin files (cyg*.dll) + + private static string appPath_ { get; } = Path.GetDirectoryName(Dinah.Core.Exe.FileLocationOnDisk); + private static string decryptLib_ { get; } = Path.Combine(appPath_, "DecryptLib"); + + public static string ffmpegPath { get; } = Path.Combine(decryptLib_, "ffmpeg.exe"); + public static string ffprobePath { get; } = Path.Combine(decryptLib_, "ffprobe.exe"); + public static string atomicParsleyPath { get; } = Path.Combine(decryptLib_, "AtomicParsley.exe"); + public static string mp4trackdumpPath { get; } = Path.Combine(decryptLib_, "mp4trackdump.exe"); + } +} diff --git a/AaxDecrypter/UNTESTED/EncodingInfo.cs b/AaxDecrypter/UNTESTED/EncodingInfo.cs new file mode 100644 index 00000000..d09a0fb1 --- /dev/null +++ b/AaxDecrypter/UNTESTED/EncodingInfo.cs @@ -0,0 +1,41 @@ +using System; +using System.Diagnostics; +using Dinah.Core.Diagnostics; + +namespace AaxDecrypter +{ + public class EncodingInfo + { + public int sampleRate { get; } = 44100; + public int channels { get; } = 2; + public int originalBitrate { get; } + + public EncodingInfo(string file) + { + var info = new ProcessStartInfo + { + FileName = DecryptSupportLibraries.ffprobePath, + Arguments = "-loglevel panic -show_streams -print_format flat \"" + file + "\"" + }; + var end = info.RunHidden().Output; + + foreach (string str2 in end.Split('\n')) + { + string[] strArray = str2.Split('='); + switch (strArray[0]) + { + case "streams.stream.0.channels": + this.channels = int.Parse(strArray[1].Replace("\"", "").TrimEnd('\r', '\n')); + break; + case "streams.stream.0.sample_rate": + this.sampleRate = int.Parse(strArray[1].Replace("\"", "").TrimEnd('\r', '\n')); + break; + case "streams.stream.0.bit_rate": + string s = strArray[1].Replace("\"", "").TrimEnd('\r', '\n'); + this.originalBitrate = (int)Math.Round(double.Parse(s) / 1000.0, MidpointRounding.AwayFromZero); + break; + } + } + } + } +} diff --git a/AaxDecrypter/UNTESTED/NFO.cs b/AaxDecrypter/UNTESTED/NFO.cs new file mode 100644 index 00000000..4e80a1ef --- /dev/null +++ b/AaxDecrypter/UNTESTED/NFO.cs @@ -0,0 +1,56 @@ +namespace AaxDecrypter +{ + public static class NFO + { + public static string CreateNfoContents(string ripper, Tags tags, EncodingInfo encodingInfo, Chapters chapters) + { + int _hours = (int)tags.duration.TotalHours; + string myDuration + = (_hours > 0 ? _hours + " hours, " : "") + + tags.duration.Minutes + " minutes, " + + tags.duration.Seconds + " seconds"; + + string str4 + = "General Information\r\n" + + "===================\r\n" + + " Title: " + tags.title + "\r\n" + + " Author: " + tags.author + "\r\n" + + " Read By: " + tags.narrator + "\r\n" + + " Copyright: " + tags.year + "\r\n" + + " Audiobook Copyright: " + tags.year + "\r\n"; + if (tags.genre != "") + { + str4 = str4 + " Genre: " + tags.genre + "\r\n"; + } + + string s + = str4 + + " Publisher: " + tags.publisher + "\r\n" + + " Duration: " + myDuration + "\r\n" + + " Chapters: " + chapters.Count() + "\r\n" + + "\r\n" + + "\r\n" + + "Media Information\r\n" + + "=================\r\n" + + " Source Format: Audible AAX\r\n" + + " Source Sample Rate: " + encodingInfo.sampleRate + " Hz\r\n" + + " Source Channels: " + encodingInfo.channels + "\r\n" + + " Source Bitrate: " + encodingInfo.originalBitrate + " kbits\r\n" + + "\r\n" + + " Lossless Encode: Yes\r\n" + + " Encoded Codec: AAC / M4B\r\n" + + " Encoded Sample Rate: " + encodingInfo.sampleRate + " Hz\r\n" + + " Encoded Channels: " + encodingInfo.channels + "\r\n" + + " Encoded Bitrate: " + encodingInfo.originalBitrate + " kbits\r\n" + + "\r\n" + + " Ripper: " + ripper + "\r\n" + + "\r\n" + + "\r\n" + + "Book Description\r\n" + + "================\r\n" + + tags.comments; + + return s; + } + } +} diff --git a/AaxDecrypter/UNTESTED/Tags.cs b/AaxDecrypter/UNTESTED/Tags.cs new file mode 100644 index 00000000..be2f4023 --- /dev/null +++ b/AaxDecrypter/UNTESTED/Tags.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using TagLib; +using TagLib.Mpeg4; +using Dinah.Core; + +namespace AaxDecrypter +{ + public class Tags + { + public string title { get; } + public string album { get; } + public string author { get; } + public string comments { get; } + public string narrator { get; } + public string year { get; } + public string publisher { get; } + public string id { get; } + public string genre { get; } + public TimeSpan duration { get; } + + public Tags(string file) + { + using (TagLib.File tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average)) + { + this.title = tagLibFile.Tag.Title.Replace(" (Unabridged)", ""); + this.album = tagLibFile.Tag.Album.Replace(" (Unabridged)", ""); + this.author = tagLibFile.Tag.FirstPerformer; + this.year = tagLibFile.Tag.Year.ToString(); + this.comments = tagLibFile.Tag.Comment; + this.duration = tagLibFile.Properties.Duration; + this.genre = tagLibFile.Tag.FirstGenre; + + var tag = tagLibFile.GetTag(TagTypes.Apple, true); + this.publisher = tag.Publisher; + this.narrator = string.IsNullOrWhiteSpace(tagLibFile.Tag.FirstComposer) ? tag.Narrator : tagLibFile.Tag.FirstComposer; + this.comments = !string.IsNullOrWhiteSpace(tag.LongDescription) ? tag.LongDescription : tag.Description; + this.id = tag.AudibleCDEK; + } + } + + public void AddAppleTags(string file) + { + using (var file1 = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average)) + { + var tag = (AppleTag)file1.GetTag(TagTypes.Apple, true); + tag.Publisher = this.publisher; + tag.LongDescription = this.comments; + tag.Description = this.comments; + file1.Save(); + } + } + + public string GenerateFfmpegTags() + { + StringBuilder stringBuilder = new StringBuilder(); + + stringBuilder.Append(";FFMETADATA1\n"); + stringBuilder.Append("major_brand=aax\n"); + stringBuilder.Append("minor_version=1\n"); + stringBuilder.Append("compatible_brands=aax M4B mp42isom\n"); + stringBuilder.Append("date=" + this.year + "\n"); + stringBuilder.Append("genre=" + this.genre + "\n"); + stringBuilder.Append("title=" + this.title + "\n"); + stringBuilder.Append("artist=" + this.author + "\n"); + stringBuilder.Append("album=" + this.album + "\n"); + stringBuilder.Append("composer=" + this.narrator + "\n"); + stringBuilder.Append("comment=" + this.comments.Truncate(254) + "\n"); + stringBuilder.Append("description=" + this.comments + "\n"); + + return stringBuilder.ToString(); + } + } +} diff --git a/AudibleDotCom/AudibleDotCom.csproj b/AudibleDotCom/AudibleDotCom.csproj new file mode 100644 index 00000000..a1930702 --- /dev/null +++ b/AudibleDotCom/AudibleDotCom.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.1 + + + + + + + diff --git a/AudibleDotCom/UNTESTED/AudiblePage.cs b/AudibleDotCom/UNTESTED/AudiblePage.cs new file mode 100644 index 00000000..4b993cd3 --- /dev/null +++ b/AudibleDotCom/UNTESTED/AudiblePage.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using Dinah.Core; + +namespace AudibleDotCom +{ + public enum AudiblePageType + { + ProductDetails = 1, + + Library = 2 + } + public static class AudiblePageExt + { + public static AudiblePage GetAudiblePageRobust(this AudiblePageType audiblePage) => AudiblePage.FromPageType(audiblePage); + } + + public abstract partial class AudiblePage : Enumeration + { + // useful for generic classes: + // public abstract class PageScraper where T : AudiblePageRobust { + // public AudiblePage AudiblePage => AudiblePageRobust.GetAudiblePageFromType(typeof(T)); + public static AudiblePageType GetAudiblePageFromType(Type audiblePageRobustType) + => (AudiblePageType)GetAll().Single(t => t.GetType() == audiblePageRobustType).Id; + + public AudiblePageType AudiblePageType { get; } + + protected AudiblePage(AudiblePageType audiblePage, string abbreviation) : base((int)audiblePage, abbreviation) => AudiblePageType = audiblePage; + + public static AudiblePage FromPageType(AudiblePageType audiblePage) => FromValue((int)audiblePage); + + /// For pages which need a param, the param is marked with {0} + protected abstract string Url { get; } + public string GetUrl(string id) => string.Format(Url, id); + + public string Abbreviation => DisplayName; + } + public abstract partial class AudiblePage : Enumeration + { + public static AudiblePage Library { get; } = LibraryPage.Instance; + public class LibraryPage : AudiblePage + { + #region singleton stuff + public static LibraryPage Instance { get; } = new LibraryPage(); + static LibraryPage() { } + private LibraryPage() : base(AudiblePageType.Library, "LIB") { } + #endregion + + protected override string Url => "http://www.audible.com/lib"; + } + } + public abstract partial class AudiblePage : Enumeration + { + public static AudiblePage Product { get; } = ProductDetailPage.Instance; + public class ProductDetailPage : AudiblePage + { + #region singleton stuff + public static ProductDetailPage Instance { get; } = new ProductDetailPage(); + static ProductDetailPage() { } + private ProductDetailPage() : base(AudiblePageType.ProductDetails, "PD") { } + #endregion + + protected override string Url => "http://www.audible.com/pd/{0}"; + } + } +} diff --git a/AudibleDotCom/UNTESTED/AudiblePageSource.cs b/AudibleDotCom/UNTESTED/AudiblePageSource.cs new file mode 100644 index 00000000..05bc5bbc --- /dev/null +++ b/AudibleDotCom/UNTESTED/AudiblePageSource.cs @@ -0,0 +1,43 @@ +using FileManager; + +namespace AudibleDotCom +{ + public class AudiblePageSource + { + public AudiblePageType AudiblePage { get; } + public string Source { get; } + public string PageId { get; } + + public AudiblePageSource(AudiblePageType audiblePage, string source, string pageId) + { + AudiblePage = audiblePage; + Source = source; + PageId = pageId; + } + + /// declawed allows local file to safely be reloaded in chrome + /// NOTE ABOUT DECLAWED FILES + /// making them safer also breaks functionality + /// eg: previously hidden parts become visible. this changes how selenium can parse pages. + /// hidden elements don't expose .Text property + public AudiblePageSource Declawed() => new AudiblePageSource(AudiblePage, FileUtility.Declaw(Source), PageId); + + public string Serialized() => $"\r\n" + Source; + + public static AudiblePageSource Deserialize(string serializedSource) + { + var endOfLine1 = serializedSource.IndexOf('\n'); + + var parameters = serializedSource + .Substring(0, endOfLine1) + .Split('|'); + var abbrev = parameters[1]; + var pageId = parameters[2]; + + var source = serializedSource.Substring(endOfLine1 + 1); + var audiblePage = AudibleDotCom.AudiblePage.FromDisplayName(abbrev).AudiblePageType; + + return new AudiblePageSource(audiblePage, source, pageId); + } + } +} diff --git a/AudibleDotComAutomation/AudibleDotComAutomation.csproj b/AudibleDotComAutomation/AudibleDotComAutomation.csproj new file mode 100644 index 00000000..35a53f2b --- /dev/null +++ b/AudibleDotComAutomation/AudibleDotComAutomation.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.1 + + + + + + + + + + + + + + + Always + + + + diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/Abstract_SeleniumRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/Abstract_SeleniumRetriever.cs new file mode 100644 index 00000000..29354f96 --- /dev/null +++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/Abstract_SeleniumRetriever.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AudibleDotCom; +using Dinah.Core.Humanizer; +using OpenQA.Selenium; +using OpenQA.Selenium.Chrome; +using OpenQA.Selenium.Support.UI; + +namespace AudibleDotComAutomation +{ + /// browser manipulation. web driver access + /// browser operators. create and store web driver, browser navigation which can vary depending on whether anon or auth'd + /// + /// this base class: is online. no auth. used for most pages. retain no chrome cookies + public abstract class SeleniumRetriever : IPageRetriever + { + #region // chrome driver details + /* + HIDING CHROME CONSOLE WINDOW + hiding chrome console window has proven to cause more headaches than it solves. here's how to do it though: + // can also use CreateDefaultService() overloads to specify driver path and/or file name + var chromeDriverService = ChromeDriverService.CreateDefaultService(); + chromeDriverService.HideCommandPromptWindow = true; + return new ChromeDriver(chromeDriverService, options); + + HEADLESS CHROME + this WOULD be how to do headless. but amazon/audible are far too tricksy about their changes and anti-scraping measures + which renders 'headless' mode useless + var options = new ChromeOptions(); + options.AddArgument("--headless"); + + SPECIFYING DRIVER LOCATION + if continues to have trouble finding driver: + var driver = new ChromeDriver(@"C:\my\path\to\chromedriver\directory"); + var chromeDriverService = ChromeDriverService.CreateDefaultService(@"C:\my\path\to\chromedriver\directory"); + */ + #endregion + + protected IWebDriver Driver { get; } + Humanizer humanizer { get; } = new Humanizer(); + + protected SeleniumRetriever() + { + Driver = new ChromeDriver(ctorCreateChromeOptions()); + } + + /// no auth. retain no chrome cookies + protected virtual ChromeOptions ctorCreateChromeOptions() => new ChromeOptions(); + + protected async Task AudibleLinkClickAsync(IWebElement element) + { + // EACH CALL to audible should have a small random wait to reduce chances of scrape detection + await humanizer.Wait(); + + await Task.Run(() => Driver.Click(element)); + + await waitForSpinnerAsync(); + + // sometimes these clicks just take a while. add a few more seconds + await Task.Delay(5000); + } + + By spinnerLocator { get; } = By.Id("library-main-overlay"); + private async Task waitForSpinnerAsync() + { + // if loading overlay w/spinner exists: pause, wait for it to end + + await Task.Delay(100); + + if (Driver.FindElements(spinnerLocator).Count > 0) + new WebDriverWait(Driver, TimeSpan.FromSeconds(60)) + .Until(ExpectedConditions.InvisibilityOfElementLocated(spinnerLocator)); + } + + private bool isFirstRun = true; + protected virtual async Task FirstRunAsync() + { + // load with no beginning wait. then wait 7 seconds to allow for page flicker. it usually happens after ~5 seconds. can happen irrespective of login state + await Task.Run(() => Driver.Navigate().GoToUrl("http://www.audible.com/")); + await Task.Delay(7000); + } + + public async Task> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null) + { + if (isFirstRun) + { + await FirstRunAsync(); + isFirstRun = false; + } + + await initFirstPageAsync(audiblePage, pageId); + + return await processUrl(audiblePage, pageId); + } + + private async Task initFirstPageAsync(AudiblePageType audiblePage, string pageId) + { + // EACH CALL to audible should have a small random wait to reduce chances of scrape detection + await humanizer.Wait(); + + var url = audiblePage.GetAudiblePageRobust().GetUrl(pageId); + await Task.Run(() => Driver.Navigate().GoToUrl(url)); + + await waitForSpinnerAsync(); + } + + private async Task> processUrl(AudiblePageType audiblePage, string pageId) + { + var pageSources = new List(); + do + { + pageSources.Add(new AudiblePageSource(audiblePage, Driver.PageSource, pageId)); + } + while (await hasMorePagesAsync()); + + return pageSources; + } + + #region has more pages + /// if no more pages, return false. else, navigate to next page and return true + private async Task hasMorePagesAsync() + { + var next = //old_hasMorePages() ?? + new_hasMorePages(); + if (next == null) + return false; + + await AudibleLinkClickAsync(next); + return true; + } + + private IWebElement old_hasMorePages() + { + var parentElements = Driver.FindElements(By.ClassName("adbl-page-next")); + if (parentElements.Count == 0) + return null; + + var childElements = parentElements[0].FindElements(By.LinkText("NEXT")); + if (childElements.Count != 1) + return null; + + return childElements[0]; + } + + // ~ oct 2017 + private IWebElement new_hasMorePages() + { + // get all active/enabled navigation links + var pageNavLinks = Driver.FindElements(By.ClassName("library-load-page")); + if (pageNavLinks.Count == 0) + return null; + + // get only the right chevron if active. + // note: there are also right chevrons which are not for wish list navigation which is why we first filter by library-load-page + var nextLink = pageNavLinks + .Where(p => p.FindElements(By.ClassName("bc-icon-chevron-right")).Count > 0) + .ToList(); // cut-off delayed execution + if (nextLink.Count == 0) + return null; + + return nextLink.Single().FindElement(By.TagName("button")); + } + #endregion + + #region IDisposable pattern + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing && Driver != null) + { + // Quit() does cleanup AND disposes + Driver.Quit(); + } + } + #endregion + } +} diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/AuthSeleniumRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/AuthSeleniumRetriever.cs new file mode 100644 index 00000000..5e32f4b4 --- /dev/null +++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/AuthSeleniumRetriever.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using OpenQA.Selenium; + +namespace AudibleDotComAutomation +{ + /// for user collections: lib, WL + public abstract class AuthSeleniumRetriever : SeleniumRetriever + { + protected bool IsLoggedIn => GetListenerPageLink() != null; + + // needed? + protected AuthSeleniumRetriever() : base() { } + + protected IWebElement GetListenerPageLink() + { + var listenerPageElement = Driver.FindElements(By.XPath("//a[contains(@href, '/review-by-author')]")); + if (listenerPageElement.Count > 0) + return listenerPageElement[0]; + return null; + } + } +} diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/BrowserlessRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/BrowserlessRetriever.cs new file mode 100644 index 00000000..9224dd66 --- /dev/null +++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/BrowserlessRetriever.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AudibleDotCom; +using CookieMonster; +using Dinah.Core; +using Dinah.Core.Humanizer; + +namespace AudibleDotComAutomation +{ + public class BrowserlessRetriever : IPageRetriever + { + Humanizer humanizer { get; } = new Humanizer(); + + public async Task> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null) + { + switch (audiblePage) + { +case AudiblePageType.Library: return await getLibraryPageSourcesAsync(); + default: throw new NotImplementedException(); + } + } + + private async Task> getLibraryPageSourcesAsync() + { + var collection = new List(); + + var cookies = await getAudibleCookiesAsync(); + + var currPageNum = 1; + bool hasMorePages; + do + { + // EACH CALL to audible should have a small random wait to reduce chances of scrape detection + await humanizer.Wait(); + +var html = await getLibraryPageAsync(cookies, currPageNum); +var pageSource = new AudiblePageSource(AudiblePageType.Library, html, null); + collection.Add(pageSource); + + hasMorePages = getHasMorePages(pageSource.Source); + + currPageNum++; + } while (hasMorePages); + + return collection; + } + + private static async Task getAudibleCookiesAsync() + { + var liveCookies = await CookiesHelper.GetLiveCookieValuesAsync(); + + var audibleCookies = liveCookies.Where(c + => c.Domain.ContainsInsensitive("audible.com") + || c.Domain.ContainsInsensitive("adbl") + || c.Domain.ContainsInsensitive("amazon.com")) + .ToList(); + + var cookies = new CookieContainer(); + foreach (var c in audibleCookies) + cookies.Add(new Cookie(c.Name, c.Value, "/", c.Domain)); + + return cookies; + } + + private static bool getHasMorePages(string html) + { + var doc = new HtmlAgilityPack.HtmlDocument(); + doc.LoadHtml(html); + + // final page, invalid page: + // + // only page: ??? + // has more pages: + // + var next_active_link = doc + .DocumentNode + .Descendants() + .FirstOrDefault(n => + n.HasClass("nextButton") && + !n.HasClass("bc-button-disabled")); + + return next_active_link != null; + } + + private static async Task getLibraryPageAsync(CookieContainer cookies, int pageNum) + { + #region // POST example (from 2017 ajax) + // var destination = "https://www.audible.com/lib-ajax"; + // var webRequest = (HttpWebRequest)WebRequest.Create(destination); + // webRequest.Method = "POST"; + // webRequest.Accept = "*/*"; + // webRequest.AllowAutoRedirect = false; + // webRequest.UserAgent = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.0.3705)"; + // webRequest.ContentType = "application/x-www-form-urlencoded; charset=UTF-8"; + // webRequest.Credentials = null; + // + // webRequest.CookieContainer = new CookieContainer(); + // webRequest.CookieContainer.Add(cookies.GetCookies(new Uri(destination))); + // + // var postData = $"progType=all&timeFilter=all&itemsPerPage={itemsPerPage}&searchTerm=&searchType=&sortColumn=&sortType=down&page={pageNum}&mode=normal&subId=&subTitle="; + // var data = Encoding.UTF8.GetBytes(postData); + // webRequest.ContentLength = data.Length; + // using (var dataStream = webRequest.GetRequestStream()) + // dataStream.Write(data, 0, data.Length); + #endregion + +var destination = "https://" + $"www.audible.com/lib?purchaseDateFilter=all&programFilter=all&sortBy=PURCHASE_DATE.dsc&page={pageNum}"; + var webRequest = (HttpWebRequest)WebRequest.Create(destination); + webRequest.UserAgent = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.0.3705)"; + + webRequest.CookieContainer = new CookieContainer(); + webRequest.CookieContainer.Add(cookies.GetCookies(new Uri(destination))); + + var webResponse = await webRequest.GetResponseAsync(); + return new StreamReader(webResponse.GetResponseStream()).ReadToEnd(); + } + + public void Dispose() { } + } +} diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/ManualLoginSeleniumRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/ManualLoginSeleniumRetriever.cs new file mode 100644 index 00000000..caa00502 --- /dev/null +++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/ManualLoginSeleniumRetriever.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; + +namespace AudibleDotComAutomation +{ + /// online. get auth by logging in with provided username and password + /// retain no chrome cookies. enter user + pw login + public class ManualLoginSeleniumRetriever : AuthSeleniumRetriever + { + string _username; + string _password; + public ManualLoginSeleniumRetriever(string username, string password) : base() + { + _username = username; + _password = password; + } + protected override async Task FirstRunAsync() + { + await base.FirstRunAsync(); + + // can't extract this into AuthSeleniumRetriever ctor. can't use username/pw until prev ctors are complete + + // click login link + await AudibleLinkClickAsync(getLoginLink()); + + // wait until login page loads + new WebDriverWait(Driver, TimeSpan.FromSeconds(60)).Until(ExpectedConditions.ElementIsVisible(By.Id("ap_email"))); + + // insert credentials + Driver + .FindElement(By.Id("ap_email")) + .SendKeys(_username); + Driver + .FindElement(By.Id("ap_password")) + .SendKeys(_password); + + // submit + var submitElement + = Driver.FindElements(By.Id("signInSubmit")).FirstOrDefault() + ?? Driver.FindElement(By.Id("signInSubmit-input")); + await AudibleLinkClickAsync(submitElement); + + // wait until audible page loads + new WebDriverWait(Driver, TimeSpan.FromSeconds(60)) + .Until(d => GetListenerPageLink()); + + if (!IsLoggedIn) + throw new Exception("not logged in"); + } + private IWebElement getLoginLink() + { + { + var loginLinkElements1 = Driver.FindElements(By.XPath("//a[contains(@href, '/signin')]")); + if (loginLinkElements1.Any()) + return loginLinkElements1[0]; + } + + // + // ADD ADDITIONAL ACCEPTABLE PATTERNS HERE + // + //{ + // var loginLinkElements2 = Driver.FindElements(By.XPath("//a[contains(@href, '/signin')]")); + // if (loginLinkElements2.Any()) + // return loginLinkElements2[0]; + //} + + throw new NotFoundException("Cannot locate login link"); + } + } +} diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/UserDataSeleniumRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/UserDataSeleniumRetriever.cs new file mode 100644 index 00000000..33219974 --- /dev/null +++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/UserDataSeleniumRetriever.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using OpenQA.Selenium.Chrome; + +namespace AudibleDotComAutomation +{ + /// online. load auth, cookies etc from user data + public class UserDataSeleniumRetriever : AuthSeleniumRetriever + { + public UserDataSeleniumRetriever() : base() + { + // can't extract this into AuthSeleniumRetriever ctor. can't use username/pw until prev ctors are complete + if (!IsLoggedIn) + throw new Exception("not logged in"); + } + + /// Use current user data/chrome cookies. DO NOT use if chrome is already open + protected override ChromeOptions ctorCreateChromeOptions() + { + var options = base.ctorCreateChromeOptions(); + + // load user data incl cookies. default on windows: + // %LOCALAPPDATA%\Google\Chrome\User Data + // C:\Users\username\AppData\Local\Google\Chrome\User Data + var chromeDefaultWindowsUserDataDir = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Google", + "Chrome", + "User Data"); + options.AddArguments($"user-data-dir={chromeDefaultWindowsUserDataDir}"); + + return options; + } + } +} diff --git a/AudibleDotComAutomation/UNTESTED/Page Retrievers/_IPageRetriever.cs b/AudibleDotComAutomation/UNTESTED/Page Retrievers/_IPageRetriever.cs new file mode 100644 index 00000000..70ca5596 --- /dev/null +++ b/AudibleDotComAutomation/UNTESTED/Page Retrievers/_IPageRetriever.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AudibleDotCom; + +namespace AudibleDotComAutomation +{ + public interface IPageRetriever : IDisposable + { + Task> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null); + } +} diff --git a/AudibleDotComAutomation/UNTESTED/Selenium.Examples.cs b/AudibleDotComAutomation/UNTESTED/Selenium.Examples.cs new file mode 100644 index 00000000..03a509c6 --- /dev/null +++ b/AudibleDotComAutomation/UNTESTED/Selenium.Examples.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; + +namespace AudibleDotComAutomation.Examples +{ + public class SeleniumExamples + { + public IWebDriver Driver { get; set; } + + IWebElement GetListenerPageLink() + { + var listenerPageElement = Driver.FindElements(By.XPath("//a[contains(@href, '/review-by-author')]")); + if (listenerPageElement.Count > 0) + return listenerPageElement[0]; + return null; + } + void wait_examples() + { + new WebDriverWait(Driver, TimeSpan.FromSeconds(60)) + .Until(ExpectedConditions.ElementIsVisible(By.Id("mast-member-acct-name"))); + + new WebDriverWait(Driver, TimeSpan.FromSeconds(60)) + .Until(d => GetListenerPageLink()); + + // https://stackoverflow.com/questions/21339339/how-to-add-custom-expectedconditions-for-selenium + new WebDriverWait(Driver, TimeSpan.FromSeconds(60)) + .Until((d) => + { + // could be refactored into OR, AND per the java selenium library + + // check 1 + var e1 = Driver.FindElements(By.Id("mast-member-acct-name")); + if (e1.Count > 0) + return e1[0]; + // check 2 + var e2 = Driver.FindElements(By.Id("header-account-info-0")); + if (e2.Count > 0) + return e2[0]; + return null; + }); + } + void XPath_examples() + { + // + // 1 + // 2 + // + // + // 3 + // 4 + // + + ReadOnlyCollection all_tr = Driver.FindElements(By.XPath("/tr")); + IWebElement first_tr = Driver.FindElement(By.XPath("/tr")); + IWebElement second_tr = Driver.FindElement(By.XPath("/tr[2]")); + // beginning with a single / starts from root + IWebElement ERROR_not_at_root = Driver.FindElement(By.XPath("/td")); + // 2 slashes searches all, NOT just descendants + IWebElement td1 = Driver.FindElement(By.XPath("//td")); + + // 2 slashes still searches all, NOT just descendants + IWebElement still_td1 = first_tr.FindElement(By.XPath("//td")); + + // dot operator starts from current node specified by first_tr + // single slash: immediate descendant + IWebElement td3 = first_tr.FindElement(By.XPath( + ".//td")); + // double slash: descendant at any depth + IWebElement td3_also = first_tr.FindElement(By.XPath( + "./td")); + + // + IWebElement find_anywhere_in_doc = first_tr.FindElement(By.XPath( + "//input[@name='asin']")); + IWebElement find_in_subsection = first_tr.FindElement(By.XPath( + ".//input[@name='asin']")); + + // search entire page. useful for: + // - RulesLocator to find something that only appears once on the page + // - non-list pages. eg: product details + var onePerPageRules = new RuleFamily + { + RowsLocator = By.XPath("/*"), // search entire page + Rules = new RuleSet { + (row, productItem) => productItem.CustomerId = row.FindElement(By.XPath("//input[@name='cust_id']")).GetValue(), + (row, productItem) => productItem.UserName = row.FindElement(By.XPath("//input[@name='user_name']")).GetValue() + } + }; + // - applying conditionals to entire page + var ruleFamily = new RuleFamily + { + RowsLocator = By.XPath("//*[starts-with(@id,'adbl-library-content-row-')]"), + // Rules = getRuleSet() + }; + } + #region Rules classes stubs + public class RuleFamily { public By RowsLocator; public IRuleClass Rules; } + public interface IRuleClass { } + public class RuleSet : IRuleClass, IEnumerable + { + public void Add(IRuleClass ruleClass) { } + public void Add(RuleAction action) { } + + public IEnumerator GetEnumerator() => throw new NotImplementedException(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => throw new NotImplementedException(); + } + public delegate void RuleAction(IWebElement row, ProductItem productItem); + public class ProductItem { public string CustomerId; public string UserName; } + #endregion + } +} diff --git a/AudibleDotComAutomation/UNTESTED/SeleniumExt.cs b/AudibleDotComAutomation/UNTESTED/SeleniumExt.cs new file mode 100644 index 00000000..386cecbb --- /dev/null +++ b/AudibleDotComAutomation/UNTESTED/SeleniumExt.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using OpenQA.Selenium; +using OpenQA.Selenium.Interactions; + +namespace AudibleDotComAutomation +{ + public static class IWebElementExt + { + // allows getting Text from elements even if hidden + // this only works on visible elements: webElement.Text + // http://yizeng.me/2014/04/08/get-text-from-hidden-elements-using-selenium-webdriver/#c-sharp + // + public static string GetText(this IWebElement webElement) => webElement.GetAttribute("textContent"); + + public static string GetValue(this IWebElement webElement) => webElement.GetAttribute("value"); + } + + public static class IWebDriverExt + { + /// Use this instead of element.Click() to ensure that the element is clicked even if it's not currently scrolled into view + public static void Click(this IWebDriver driver, IWebElement element) + { + // from: https://stackoverflow.com/questions/12035023/selenium-webdriver-cant-click-on-a-link-outside-the-page + + + //// this works but isn't really the same + //element.SendKeys(Keys.Enter); + + + //// didn't work for me + //new Actions(driver) + // .MoveToElement(element) + // .Click() + // .Build() + // .Perform(); + + driver.ScrollIntoView(element); + element.Click(); + } + public static void ScrollIntoView(this IWebDriver driver, IWebElement element) + => ((IJavaScriptExecutor)driver).ExecuteScript($"window.scroll({element.Location.X}, {element.Location.Y})"); + } +} diff --git a/AudibleDotComAutomation/chromedriver.exe b/AudibleDotComAutomation/chromedriver.exe new file mode 100644 index 00000000..f8b34f1b Binary files /dev/null and b/AudibleDotComAutomation/chromedriver.exe differ diff --git a/CookieMonster/CookieMonster.csproj b/CookieMonster/CookieMonster.csproj new file mode 100644 index 00000000..84fe44ec --- /dev/null +++ b/CookieMonster/CookieMonster.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.1 + + + + + + + + + + + + diff --git a/CookieMonster/UNTESTED/Browsers/Chrome.cs b/CookieMonster/UNTESTED/Browsers/Chrome.cs new file mode 100644 index 00000000..fb0034c3 --- /dev/null +++ b/CookieMonster/UNTESTED/Browsers/Chrome.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Data.SQLite; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using FileManager; + +namespace CookieMonster +{ + internal class Chrome : IBrowser + { + public async Task> GetAllCookiesAsync() + { + var col = new List(); + + var strPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Google\Chrome\User Data\Default\Cookies"); + if (!FileUtility.FileExists(strPath)) + return col; + + // + // IF WE GET AN ERROR HERE + // then add a reference to sqlite core in the project which is ultimately calling this. + // a project which directly references CookieMonster doesn't need to also ref sqlite. + // however, for any further number of abstractions, the project needs to directly ref sqlite. + // eg: this will not work unless the winforms proj adds sqlite to ref.s: + // LibationWinForm > AudibleDotComAutomation > CookieMonster + // + using (var conn = new SQLiteConnection("Data Source=" + strPath + ";pooling=false")) + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT host_key, name, value, encrypted_value, last_access_utc, expires_utc FROM cookies;"; + + conn.Open(); + using (var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false)) + { + while (reader.Read()) + { + var host_key = reader.GetString(0); + var name = reader.GetString(1); + var value = reader.GetString(2); + var last_access_utc = reader.GetInt64(4); + var expires_utc = reader.GetInt64(5); + + // https://stackoverflow.com/a/25874366 + if (string.IsNullOrWhiteSpace(value)) + { + var encrypted_value = (byte[])reader[3]; + var decodedData = System.Security.Cryptography.ProtectedData.Unprotect(encrypted_value, null, System.Security.Cryptography.DataProtectionScope.CurrentUser); + value = Encoding.ASCII.GetString(decodedData); + } + + col.Add(new CookieValue { Browser = "chrome", Domain = host_key, Name = name, Value = value, LastAccess = chromeTimeToDateTimeUtc(last_access_utc), Expires = chromeTimeToDateTimeUtc(expires_utc) }); + } + } + } + + return col; + } + + // Chrome uses 1601-01-01 00:00:00 UTC as the epoch (ie the starting point for the millisecond time counter). + // this is the same as "FILETIME" in Win32 except FILETIME uses 100ns ticks instead of ms. + private static DateTime chromeTimeToDateTimeUtc(long time) => DateTime.SpecifyKind(DateTime.FromFileTime(time * 10), DateTimeKind.Utc); + } +} diff --git a/CookieMonster/UNTESTED/Browsers/FireFox.cs b/CookieMonster/UNTESTED/Browsers/FireFox.cs new file mode 100644 index 00000000..086f9426 --- /dev/null +++ b/CookieMonster/UNTESTED/Browsers/FireFox.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Data.SQLite; +using System.IO; +using System.Threading.Tasks; +using FileManager; + +namespace CookieMonster +{ + internal class FireFox : IBrowser + { + public async Task> GetAllCookiesAsync() + { + var col = new List(); + + string strPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @"Mozilla\Firefox\Profiles"); + if (!FileUtility.FileExists(strPath)) + return col; + var dirs = new DirectoryInfo(strPath).GetDirectories("*.default"); + if (dirs.Length != 1) + return col; + strPath = Path.Combine(strPath, dirs[0].Name, "cookies.sqlite"); + if (!FileUtility.FileExists(strPath)) + return col; + + // First copy the cookie jar so that we can read the cookies from unlocked copy while FireFox is running + var strTemp = strPath + ".temp"; + + File.Copy(strPath, strTemp, true); + + // Now open the temporary cookie jar and extract Value from the cookie if we find it. + using (var conn = new SQLiteConnection("Data Source=" + strTemp + ";pooling=false")) + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "SELECT host, name, value, lastAccessed, expiry FROM moz_cookies; "; + + conn.Open(); + using (var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false)) + { + while (reader.Read()) + { + var host_key = reader.GetString(0); + var name = reader.GetString(1); + var value = reader.GetString(2); + var lastAccessed = reader.GetInt32(3); + var expiry = reader.GetInt32(4); + + col.Add(new CookieValue { Browser = "firefox", Domain = host_key, Name = name, Value = value, LastAccess = lastAccessedToDateTime(lastAccessed), Expires = expiryToDateTime(expiry) }); + } + } + } + + if (FileUtility.FileExists(strTemp)) + File.Delete(strTemp); + + return col; + } + + // time is in microseconds since unix epoch + private static DateTime lastAccessedToDateTime(int time) => new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(time); + + // time is in normal seconds since unix epoch + private static DateTime expiryToDateTime(int time) => new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc).AddSeconds(time); + } +} diff --git a/CookieMonster/UNTESTED/Browsers/IBrowser.cs b/CookieMonster/UNTESTED/Browsers/IBrowser.cs new file mode 100644 index 00000000..cb37cece --- /dev/null +++ b/CookieMonster/UNTESTED/Browsers/IBrowser.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace CookieMonster +{ + internal interface IBrowser + { + Task> GetAllCookiesAsync(); + } +} diff --git a/CookieMonster/UNTESTED/Browsers/InternetExplorer.cs b/CookieMonster/UNTESTED/Browsers/InternetExplorer.cs new file mode 100644 index 00000000..39f95c7f --- /dev/null +++ b/CookieMonster/UNTESTED/Browsers/InternetExplorer.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CookieMonster +{ + internal class InternetExplorer : IBrowser + { + public async Task> GetAllCookiesAsync() + { + // real locations of Windows Cookies folders + // + // Windows 7: + // C:\Users\username\AppData\Roaming\Microsoft\Windows\Cookies + // C:\Users\username\AppData\Roaming\Microsoft\Windows\Cookies\Low + // + // Windows 8, Windows 8.1, Windows 10: + // C:\Users\username\AppData\Local\Microsoft\Windows\INetCookies + // C:\Users\username\AppData\Local\Microsoft\Windows\INetCookies\Low + + var strPath = Environment.GetFolderPath(Environment.SpecialFolder.Cookies); + + var col = (await getIECookiesAsync(strPath).ConfigureAwait(false)).ToList(); + col = col.Concat(await getIECookiesAsync(Path.Combine(strPath, "Low"))).ToList(); + + return col; + } + + private static async Task> getIECookiesAsync(string strPath) + { + var cookies = new List(); + + var files = await Task.Run(() => Directory.EnumerateFiles(strPath, "*.txt")); + foreach (string path in files) + { + var cookiesInFile = new List(); + + var cookieLines = File.ReadAllLines(path); + CookieValue currCookieVal = null; + for (var i = 0; i < cookieLines.Length; i++) + { + var line = cookieLines[i]; + + // IE cookie format + // 0 Cookie name + // 1 Cookie value + // 2 Host / path for the web server setting the cookie + // 3 Flags + // 4 Expiration time (low int) + // 5 Expiration time (high int) + // 6 Creation time (low int) + // 7 Creation time (high int) + // 8 Record delimiter == "*" + var pos = i % 9; + long expLoTemp = 0; + long creatLoTemp = 0; + if (pos == 0) + { + currCookieVal = new CookieValue { Browser = "ie", Name = line }; + cookiesInFile.Add(currCookieVal); + } + else if (pos == 1) + currCookieVal.Value = line; + else if (pos == 2) + currCookieVal.Domain = line; + else if (pos == 4) + expLoTemp = Int64.Parse(line); + else if (pos == 5) + currCookieVal.Expires = LoHiToDateTime(expLoTemp, Int64.Parse(line)); + else if (pos == 6) + creatLoTemp = Int64.Parse(line); + else if (pos == 7) + currCookieVal.LastAccess = LoHiToDateTime(creatLoTemp, Int64.Parse(line)); + } + + cookies.AddRange(cookiesInFile); + } + + return cookies; + } + + private static DateTime LoHiToDateTime(long lo, long hi) => DateTime.FromFileTimeUtc(((hi << 32) + lo)); + } +} diff --git a/CookieMonster/UNTESTED/CookieValue.cs b/CookieMonster/UNTESTED/CookieValue.cs new file mode 100644 index 00000000..c1fdde97 --- /dev/null +++ b/CookieMonster/UNTESTED/CookieValue.cs @@ -0,0 +1,32 @@ +using System; + +namespace CookieMonster +{ + public class CookieValue + { + public string Browser { get; set; } + + public string Name { get; set; } + public string Value { get; set; } + public string Domain { get; set; } + + public DateTime LastAccess { get; set; } + public DateTime Expires { get; set; } + + public bool IsValid + { + get + { + // sanity check. datetimes are stored weird in each cookie type. make sure i haven't converted these incredibly wrong. + // some early conversion attempts produced years like 42, 1955, 4033 + var _5yearsPast = DateTime.UtcNow.AddYears(-5); + if (LastAccess < _5yearsPast || LastAccess > DateTime.UtcNow) + return false; + // don't check expiry. some sites are setting stupid values for year. eg: 9999 + return true; + } + } + + public bool HasExpired => Expires < DateTime.UtcNow; + } +} diff --git a/CookieMonster/UNTESTED/CookiesHelper.cs b/CookieMonster/UNTESTED/CookiesHelper.cs new file mode 100644 index 00000000..3e7ad1f7 --- /dev/null +++ b/CookieMonster/UNTESTED/CookiesHelper.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dinah.Core.Collections.Generic; + +namespace CookieMonster +{ + public static class CookiesHelper + { + internal static IEnumerable GetBrowsers() + => AppDomain.CurrentDomain + .GetAssemblies() + .SelectMany(s => s.GetTypes()) + .Where(p => typeof(IBrowser).IsAssignableFrom(p) && !p.IsAbstract && !p.IsInterface) + .Select(t => Activator.CreateInstance(t) as IBrowser) + .ToList(); + + /// all. including expired + public static async Task> GetAllCookieValuesAsync() + { + //// foreach{await} runs in serial + //var allCookies = new List(); + //foreach (var b in GetBrowsers()) + //{ + // var browserCookies = await b.GetAllCookiesAsync().ConfigureAwait(false); + // allCookies.AddRange(browserCookies); + //} + + //// WhenAll runs in parallel + // this 1st step LOOKS like a bug which runs each method until completion. However, since we don't use await, it's actually returning a Task. That resulting task is awaited asynchronously + var browserTasks = GetBrowsers().Select(b => b.GetAllCookiesAsync()); + var results = await Task.WhenAll(browserTasks).ConfigureAwait(false); + var allCookies = results.SelectMany(a => a).ToList(); + + if (allCookies.Any(c => !c.IsValid)) + throw new Exception("some date time was converted way too far"); + + foreach (var c in allCookies) + c.Domain = c.Domain.TrimEnd('/'); + + // for each domain+name, only keep the 1 with the most recent access + var sortedCookies = allCookies + .OrderByDescending(c => c.LastAccess) + .DistinctBy(c => new { c.Domain, c.Name }) + .ToList(); + + return sortedCookies; + } + + /// not expired + public static async Task> GetLiveCookieValuesAsync() + => (await GetAllCookieValuesAsync().ConfigureAwait(false)) + .Where(c => !c.HasExpired) + .ToList(); + } +} diff --git a/DataLayer/DataLayer.csproj b/DataLayer/DataLayer.csproj new file mode 100644 index 00000000..4252ca1c --- /dev/null +++ b/DataLayer/DataLayer.csproj @@ -0,0 +1,38 @@ + + + + netcoreapp3.0;netstandard2.1 + + + + true + + Library + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + PreserveNewest + + + + diff --git a/DataLayer/UNTESTED/Commands/RemoveOrphans.cs b/DataLayer/UNTESTED/Commands/RemoveOrphans.cs new file mode 100644 index 00000000..60fd1b4d --- /dev/null +++ b/DataLayer/UNTESTED/Commands/RemoveOrphans.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer +{ + public static class RemoveOrphansCommand + { + public static int RemoveOrphans(this LibationContext context) + => context.Database.ExecuteSqlCommand(@" + delete c + from Contributors c + left join BookContributor bc on c.ContributorId = bc.ContributorId + left join Books b on bc.BookId = b.BookId + where bc.ContributorId is null + "); + } +} diff --git a/DataLayer/UNTESTED/Configurations/BookConfig.cs b/DataLayer/UNTESTED/Configurations/BookConfig.cs new file mode 100644 index 00000000..d9e00457 --- /dev/null +++ b/DataLayer/UNTESTED/Configurations/BookConfig.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations +{ + internal class BookConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.HasKey(b => b.BookId); + entity.HasIndex(b => b.AudibleProductId); + + entity.OwnsOne(b => b.Rating); + + // + // CRUCIAL: ignore unmapped collections, even get-only + // + entity.Ignore(nameof(Book.Authors)); + entity.Ignore(nameof(Book.Narrators)); + //// these don't seem to matter + //entity.Ignore(nameof(Book.AuthorNames)); + //entity.Ignore(nameof(Book.NarratorNames)); + //entity.Ignore(nameof(Book.HasPdfs)); + + // OwnsMany: "Can only ever appear on navigation properties of other entity types. + // Are automatically loaded, and can only be tracked by a DbContext alongside their owner." + entity.OwnsMany(b => b.Supplements); + // even though it's owned, we need to map its backing field + entity + .Metadata + .FindNavigation(nameof(Book.Supplements)) + .SetPropertyAccessMode(PropertyAccessMode.Field); + + // owns it 1:1, but store in separate table + entity + .OwnsOne(b => b.UserDefinedItem, b_udi => b_udi.ToTable(nameof(Book.UserDefinedItem))); + // UserDefinedItem must link back to book so we know how to log changed tags. + // ie: when a tag changes, we need to get the parent book's product id + entity + .HasOne(b => b.UserDefinedItem) + .WithOne(udi => udi.Book) + .HasForeignKey(udi => udi.BookId); + + entity + .Metadata + .FindNavigation(nameof(Book.ContributorsLink)) + // PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field + .SetPropertyAccessMode(PropertyAccessMode.Field); + + entity + .Metadata + .FindNavigation(nameof(Book.SeriesLink)) + // PropertyAccessMode.Field : Series is a get-only property, not a field, so use its backing field + .SetPropertyAccessMode(PropertyAccessMode.Field); + + entity + .HasOne(b => b.Category) + .WithMany() + .HasForeignKey(b => b.CategoryId); + } + } +} \ No newline at end of file diff --git a/DataLayer/UNTESTED/Configurations/BookContributorConfig.cs b/DataLayer/UNTESTED/Configurations/BookContributorConfig.cs new file mode 100644 index 00000000..f3bc09c7 --- /dev/null +++ b/DataLayer/UNTESTED/Configurations/BookContributorConfig.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations +{ + internal class BookContributorConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.HasKey(bc => new { bc.BookId, bc.ContributorId, bc.Role }); + + entity.HasIndex(b => b.BookId); + entity.HasIndex(b => b.ContributorId); + + entity + .HasOne(bc => bc.Book) + .WithMany(b => b.ContributorsLink) + .HasForeignKey(bc => bc.BookId); + entity + .HasOne(bc => bc.Contributor) + .WithMany(c => c.BooksLink) + .HasForeignKey(bc => bc.ContributorId); + } + } +} \ No newline at end of file diff --git a/DataLayer/UNTESTED/Configurations/CategoryConfig.cs b/DataLayer/UNTESTED/Configurations/CategoryConfig.cs new file mode 100644 index 00000000..3b1fcde7 --- /dev/null +++ b/DataLayer/UNTESTED/Configurations/CategoryConfig.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations +{ + internal class CategoryConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.HasKey(c => c.CategoryId); + entity.HasIndex(c => c.AudibleCategoryId); + } + } +} \ No newline at end of file diff --git a/DataLayer/UNTESTED/Configurations/ContributorConfig.cs b/DataLayer/UNTESTED/Configurations/ContributorConfig.cs new file mode 100644 index 00000000..581b4e01 --- /dev/null +++ b/DataLayer/UNTESTED/Configurations/ContributorConfig.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations +{ + internal class ContributorConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.HasKey(c => c.ContributorId); + entity.HasIndex(c => c.Name); + + //entity.OwnsOne(b => b.AuthorProperty); + // ... in separate table + + entity + .Metadata + .FindNavigation(nameof(Contributor.BooksLink)) + .SetPropertyAccessMode(PropertyAccessMode.Field); + } + } +} \ No newline at end of file diff --git a/DataLayer/UNTESTED/Configurations/LibraryBookConfig.cs b/DataLayer/UNTESTED/Configurations/LibraryBookConfig.cs new file mode 100644 index 00000000..b66f5ea6 --- /dev/null +++ b/DataLayer/UNTESTED/Configurations/LibraryBookConfig.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations +{ + internal class LibraryBookConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.HasKey(b => b.BookId); + + entity + .HasOne(le => le.Book) + .WithOne() + .HasForeignKey(le => le.BookId); + } + } +} \ No newline at end of file diff --git a/DataLayer/UNTESTED/Configurations/SeriesBookConfig.cs b/DataLayer/UNTESTED/Configurations/SeriesBookConfig.cs new file mode 100644 index 00000000..c848fa9a --- /dev/null +++ b/DataLayer/UNTESTED/Configurations/SeriesBookConfig.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations +{ + internal class SeriesBookConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.HasKey(bc => new { bc.SeriesId, bc.BookId }); + + entity.HasIndex(b => b.SeriesId); + entity.HasIndex(b => b.BookId); + + entity + .HasOne(sb => sb.Series) + .WithMany(s => s.BooksLink) + .HasForeignKey(sb => sb.SeriesId); + entity + .HasOne(sb => sb.Book) + .WithMany(b => b.SeriesLink) + .HasForeignKey(sb => sb.BookId); + } + } +} \ No newline at end of file diff --git a/DataLayer/UNTESTED/Configurations/SeriesConfig.cs b/DataLayer/UNTESTED/Configurations/SeriesConfig.cs new file mode 100644 index 00000000..7d067cf4 --- /dev/null +++ b/DataLayer/UNTESTED/Configurations/SeriesConfig.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations +{ + internal class SeriesConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.HasKey(b => b.SeriesId); + entity.HasIndex(b => b.AudibleSeriesId); + + entity + .Metadata + .FindNavigation(nameof(Series.BooksLink)) + .SetPropertyAccessMode(PropertyAccessMode.Field); + } + } +} \ No newline at end of file diff --git a/DataLayer/UNTESTED/Configurations/SupplementConfig.cs b/DataLayer/UNTESTED/Configurations/SupplementConfig.cs new file mode 100644 index 00000000..6106090a --- /dev/null +++ b/DataLayer/UNTESTED/Configurations/SupplementConfig.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations +{ + internal class SupplementConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.HasKey(s => s.SupplementId); + entity + .HasOne(s => s.Book) + .WithMany(b => b.Supplements) + .HasForeignKey(s => s.BookId); + } + } +} diff --git a/DataLayer/UNTESTED/Configurations/UserDefinedItemConfig.cs b/DataLayer/UNTESTED/Configurations/UserDefinedItemConfig.cs new file mode 100644 index 00000000..ead79725 --- /dev/null +++ b/DataLayer/UNTESTED/Configurations/UserDefinedItemConfig.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations +{ + internal class UserDefinedItemConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.OwnsOne(p => p.Rating); + } + } +} diff --git a/DataLayer/UNTESTED/EfClasses/Book.cs b/DataLayer/UNTESTED/EfClasses/Book.cs new file mode 100644 index 00000000..adc2a8ee --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/Book.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dinah.Core; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer +{ + public class AudibleProductId + { + public string Id { get; } + public AudibleProductId(string id) + { + ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id)); + Id = id; + } + } + public class Book + { + // implementation detail. set by db only. only used by data layer + internal int BookId { get; private set; } + + // immutable + public string AudibleProductId { get; private set; } + public string Title { get; private set; } + public string Description { get; private set; } + public int LengthInMinutes { get; private set; } + + // mutable + public string PictureId { get; set; } + + // book details + public bool HasBookDetails { get; private set; } + public bool IsAbridged { get; private set; } + public DateTime? DatePublished { get; private set; } + + // non-null. use "empty pattern" + internal int CategoryId { get; private set; } + public Category Category { get; private set; } + public string[] CategoriesNames + => Category == null ? new string[0] + : Category.ParentCategory == null ? new[] { Category.Name } + : new[] { Category.ParentCategory.Name, Category.Name }; + public string[] CategoriesIds + => Category == null ? null + : Category.ParentCategory == null ? new[] { Category.AudibleCategoryId } + : new[] { Category.ParentCategory.AudibleCategoryId, Category.AudibleCategoryId }; + + // is owned, not optional 1:1 + public UserDefinedItem UserDefinedItem { get; private set; } + + // is owned, not optional 1:1 + /// The product's aggregate community rating + public Rating Rating { get; private set; } = new Rating(0, 0, 0); + + // ef-ctor + private Book() { } + // non-ef ctor + /// special id class b/c it's too easy to get string order mixed up + public Book( + AudibleProductId audibleProductId, + string title, + string description, + int lengthInMinutes, + IEnumerable authors, + IEnumerable narrators) + { + // validate + ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId)); + var productId = audibleProductId.Id; + ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId)); + ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title)); + + // non-ef-ctor init.s + UserDefinedItem = new UserDefinedItem(this); + _contributorsLink = new HashSet(); + _seriesLink = new HashSet(); + _supplements = new HashSet(); + + // since category/id is never null, nullity means it hasn't been loaded + CategoryId = Category.GetEmpty().CategoryId; + + // simple assigns + AudibleProductId = productId; + Title = title; + Description = description; + LengthInMinutes = lengthInMinutes; + + // assigns with biz logic + ReplaceAuthors(authors); + ReplaceNarrators(narrators); + + // import previously saved tags + // do this immediately. any save occurs before reloading tags will overwrite persistent tags with new blank entries; all old persisted tags will be lost + // if refactoring, DO NOT use "ProductId" before it's assigned to. to be safe, just use "productId" + UserDefinedItem = new UserDefinedItem(this) { Tags = FileManager.TagsPersistence.GetTags(productId) }; + } + + #region contributors, authors, narrators + // use uninitialised backing fields - this means we can detect if the collection was loaded + private HashSet _contributorsLink; + // i'd like this to be internal but migration throws this exception when i try: + // Value cannot be null. + // Parameter name: property + public IEnumerable ContributorsLink + => _contributorsLink? + .OrderBy(bc => bc.Order) + .ToList(); + + public IEnumerable Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList(); + public string AuthorNames => string.Join(", ", Authors.Select(a => a.Name)); + + public IEnumerable Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList(); + public string NarratorNames => string.Join(", ", Narrators.Select(n => n.Name)); + + public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name; + + public void ReplaceAuthors(IEnumerable authors, DbContext context = null) + => replaceContributors(authors, Role.Author, context); + public void ReplaceNarrators(IEnumerable narrators, DbContext context = null) + => replaceContributors(narrators, Role.Narrator, context); + public void ReplacePublisher(Contributor publisher, DbContext context = null) + => replaceContributors(new List { publisher }, Role.Publisher, context); + private void replaceContributors(IEnumerable newContributors, Role role, DbContext context = null) + { + ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors)); + + // the edge cases of doing local-loaded vs remote-only got weird. just load it + if (_contributorsLink == null) + { + ArgumentValidator.EnsureNotNull(context, nameof(context)); + if (!context.Entry(this).IsKeySet) + throw new InvalidOperationException("Could not add contributors"); + + context.Entry(this).Collection(s => s.ContributorsLink).Load(); + } + + var roleContributions = getContributions(role); + var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors); + if (isIdentical) + return; + + _contributorsLink.RemoveWhere(bc => bc.Role == role); + addNewContributors(newContributors, role); + } + private void addNewContributors(IEnumerable newContributors, Role role) + { + byte order = 0; + var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++)); + var newContributions = new HashSet(newContributionsEnum); + _contributorsLink.UnionWith(newContributions); + } + + private List getContributions(Role role) + => ContributorsLink + .Where(a => a.Role == role) + .OrderBy(a => a.Order) + .ToList(); + #endregion + + #region series + private HashSet _seriesLink; + public IEnumerable SeriesLink => _seriesLink?.ToList(); + public string SeriesNames + { + get + { + // first: alphabetical by name + var withNames = _seriesLink + .Where(s => !string.IsNullOrWhiteSpace(s.Series.Name)) + .Select(s => s.Series.Name) + .OrderBy(a => a) + .ToList(); + // then un-named are alpha by series id + var nullNames = _seriesLink + .Where(s => string.IsNullOrWhiteSpace(s.Series.Name)) + .Select(s => s.Series.AudibleSeriesId) + .OrderBy(a => a) + .ToList(); + + var all = withNames.Union(nullNames).ToList(); + return string.Join(", ", all); + } + } + + public void UpsertSeries(Series series, float? index = null, DbContext context = null) + { + ArgumentValidator.EnsureNotNull(series, nameof(series)); + + // our add() is conditional upon what's already included in the collection. + // therefore if not loaded, a trip is required. might as well just load it + if (_seriesLink == null) + { + ArgumentValidator.EnsureNotNull(context, nameof(context)); + if (!context.Entry(this).IsKeySet) + throw new InvalidOperationException("Could not add series"); + + context.Entry(this).Collection(s => s.SeriesLink).Load(); + } + + var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series); + if (singleSeriesBook == null) + _seriesLink.Add(new SeriesBook(series, this, index)); + else + singleSeriesBook.UpdateIndex(index); + } + #endregion + + #region supplements + private HashSet _supplements; + public IEnumerable Supplements => _supplements?.ToList(); + public bool HasPdfs => Supplements.Any(); + + public void AddSupplementDownloadUrl(string url) + { + // supplements are owned by Book, so no need to Load(): + // OwnsMany: "Can only ever appear on navigation properties of other entity types. + // Are automatically loaded, and can only be tracked by a DbContext alongside their owner." + + ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url)); + url = FileManager.FileUtility.RestoreDeclawed(url); + + if (!_supplements.Any(s => url.EqualsInsensitive(url))) + _supplements.Add(new Supplement(this, url)); + } + #endregion + + public void UpdateProductRating(float overallRating, float performanceRating, float storyRating) + => Rating.Update(overallRating, performanceRating, storyRating); + + public void UpdateBookDetails(bool isAbridged, DateTime? datePublished) + { + // don't overwrite with default values + IsAbridged |= isAbridged; + DatePublished = datePublished ?? DatePublished; + + HasBookDetails = true; + } + + public void UpdateCategory(Category category, DbContext context = null) + { + // since category is never null, nullity means it hasn't been loaded + if (Category != null || CategoryId == Category.GetEmpty().CategoryId) + { + Category = category; + return; + } + + if (context == null) + throw new Exception("need context"); + + context.Entry(this).Reference(s => s.Category).Load(); + Category = category; + } + } +} diff --git a/DataLayer/UNTESTED/EfClasses/BookContributor.cs b/DataLayer/UNTESTED/EfClasses/BookContributor.cs new file mode 100644 index 00000000..56e5e020 --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/BookContributor.cs @@ -0,0 +1,27 @@ +using Dinah.Core; + +namespace DataLayer +{ + public class BookContributor + { + internal int BookId { get; private set; } + internal int ContributorId { get; private set; } + public Role Role { get; private set; } + public byte Order { get; private set; } + + public Book Book { get; private set; } + public Contributor Contributor { get; private set; } + + private BookContributor() { } + internal BookContributor(Book book, Contributor contributor, Role role, byte order) + { + ArgumentValidator.EnsureNotNull(book, nameof(book)); + ArgumentValidator.EnsureNotNull(contributor, nameof(contributor)); + + Book = book; + Contributor = contributor; + Role = role; + Order = order; + } + } +} diff --git a/DataLayer/UNTESTED/EfClasses/Category.cs b/DataLayer/UNTESTED/EfClasses/Category.cs new file mode 100644 index 00000000..db8af11c --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/Category.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dinah.Core; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer +{ + public class AudibleCategoryId + { + public string Id { get; } + public AudibleCategoryId(string id) + { + ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id)); + Id = id; + } + } + public class Category + { + // Empty is a special case. use private ctor w/o validation + public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "", ParentCategory = null }; + public bool IsEmpty() => string.IsNullOrWhiteSpace(AudibleCategoryId) || string.IsNullOrWhiteSpace(Name) || ParentCategory == null; + + internal int CategoryId { get; private set; } + public string AudibleCategoryId { get; private set; } + + public string Name { get; private set; } + public Category ParentCategory { get; private set; } + + private Category() { } + /// special id class b/c it's too easy to get string order mixed up + public Category(AudibleCategoryId audibleSeriesId, string name, Category parentCategory = null) + { + ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId)); + var id = audibleSeriesId.Id; + ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id)); + ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name)); + + AudibleCategoryId = id; + Name = name; + + UpdateParentCategory(parentCategory); + } + + public void UpdateParentCategory(Category parentCategory) + { + // don't overwrite with null but not an error + if (parentCategory != null) + ParentCategory = parentCategory; + } + } +} diff --git a/DataLayer/UNTESTED/EfClasses/Contributor.cs b/DataLayer/UNTESTED/EfClasses/Contributor.cs new file mode 100644 index 00000000..587f38c6 --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/Contributor.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Linq; +using Dinah.Core; + +namespace DataLayer +{ + public class Contributor + { + // contributors search links are just name with url-encoding. space can be + or %20 + // author search link: /search?searchAuthor=Robert+Bevan + // narrator search link: /search?searchNarrator=Robert+Bevan + // can also search multiples. concat with comma before url encode + + // id.s + // ---- + // https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2 + // goes to summary page + // at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman + // some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears + // all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman + + internal int ContributorId { get; private set; } + public string Name { get; private set; } + + private HashSet _booksLink; + public IEnumerable BooksLink => _booksLink?.ToList(); + + private Contributor() { } + public Contributor(string name) + { + ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name)); + + _booksLink = new HashSet(); + + Name = name; + } + + public string AudibleAuthorId { get; private set; } + public void UpdateAudibleAuthorId(string authorId) + { + // don't overwrite with null or whitespace but not an error + if (!string.IsNullOrWhiteSpace(authorId)) + AudibleAuthorId = authorId; + } + + #region // AudibleAuthorId refactor: separate author-specific info. overkill for a single optional string + ///// Most authors in Audible have a unique id + //public AudibleAuthorProperty AudibleAuthorProperty { get; private set; } + //public void UpdateAuthorId(string authorId, LibationContext context = null) + //{ + // if (authorId == null) + // return; + // if (AudibleAuthorProperty != null) + // { + // AudibleAuthorProperty.UpdateAudibleAuthorId(authorId); + // return; + // } + // if (context == null) + // throw new ArgumentNullException(nameof(context), "You must provide a context"); + // if (context.Contributors.Find(ContributorId) == null) + // throw new InvalidOperationException("Could not update audible author id."); + // var audibleAuthorProperty = new AudibleAuthorProperty(); + // audibleAuthorProperty.UpdateAudibleAuthorId(authorId); + // context.AuthorProperties.Add(audibleAuthorProperty); + //} + //public class AudibleAuthorProperty + //{ + // public int ContributorId { get; private set; } + // public Contributor Contributor { get; set; } + + // public string AudibleAuthorId { get; private set; } + + // public void UpdateAudibleAuthorId(string authorId) + // { + // if (!string.IsNullOrWhiteSpace(authorId)) + // AudibleAuthorId = authorId; + // } + //} + //// ...and create EF table config + #endregion + } +} diff --git a/DataLayer/UNTESTED/EfClasses/LibraryBook.cs b/DataLayer/UNTESTED/EfClasses/LibraryBook.cs new file mode 100644 index 00000000..3146e921 --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/LibraryBook.cs @@ -0,0 +1,25 @@ +using System; +using Dinah.Core; + +namespace DataLayer +{ + public class LibraryBook + { + internal int BookId { get; private set; } + public Book Book { get; private set; } + + public DateTime DateAdded { get; private set; } + + /// For downloading AAX file + public string DownloadBookLink { get; private set; } + + private LibraryBook() { } + public LibraryBook(Book book, DateTime dateAdded, string downloadBookLink) + { + ArgumentValidator.EnsureNotNull(book, nameof(book)); + Book = book; + DateAdded = dateAdded; + DownloadBookLink = downloadBookLink; + } + } +} diff --git a/DataLayer/UNTESTED/EfClasses/Rating.cs b/DataLayer/UNTESTED/EfClasses/Rating.cs new file mode 100644 index 00000000..22adb22b --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/Rating.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using Dinah.Core; + +namespace DataLayer +{ + /// Parameterless ctor and setters should be used by EF only. Everything else should treat it as immutable + public class Rating : ValueObject_Static + { + public float OverallRating { get; private set; } + public float PerformanceRating { get; private set; } + public float StoryRating { get; private set; } + + private Rating() { } + internal Rating(float overallRating, float performanceRating, float storyRating) + { + OverallRating = overallRating; + PerformanceRating = performanceRating; + StoryRating = storyRating; + } + + // EF magically tracks this owned object. by replacing it with a new() immutable object, stuff gets weird. update instead + internal void Update(float overallRating, float performanceRating, float storyRating) + { + // don't overwrite with all 0 + if (overallRating + performanceRating + storyRating == 0) + return; + + OverallRating = overallRating; + PerformanceRating = performanceRating; + StoryRating = storyRating; + } + + protected override IEnumerable GetEqualityComponents() + { + yield return OverallRating; + yield return PerformanceRating; + yield return StoryRating; + } + + public float FirstScore + => OverallRating > 0 ? OverallRating + : PerformanceRating > 0 ? PerformanceRating + : StoryRating; + + /// character: ★ + const char STAR = '\u2605'; + /// character: ½ + const char HALF = '\u00BD'; + string getStars(float score) + { + var fullStars = (int)Math.Floor(score); + + var starString = "".PadLeft(fullStars, STAR); + + if (score - fullStars == 0.5f) + starString += HALF; + + return starString; + } + + public string ToStarString() + { + var items = new List(); + + if (OverallRating > 0) + items.Add($"Overall: {getStars(OverallRating)}"); + if (PerformanceRating > 0) + items.Add($"Perform: {getStars(PerformanceRating)}"); + if (StoryRating > 0) + items.Add($"Story: {getStars(StoryRating)}"); + + return string.Join("\r\n", items); + } + } +} diff --git a/DataLayer/UNTESTED/EfClasses/Role.cs b/DataLayer/UNTESTED/EfClasses/Role.cs new file mode 100644 index 00000000..3dd8f4a0 --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/Role.cs @@ -0,0 +1,4 @@ +namespace DataLayer +{ + public enum Role { Author = 1, Narrator = 2, Publisher = 3 } +} diff --git a/DataLayer/UNTESTED/EfClasses/Series.cs b/DataLayer/UNTESTED/EfClasses/Series.cs new file mode 100644 index 00000000..73222f99 --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/Series.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dinah.Core; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer +{ + public class AudibleSeriesId + { + public string Id { get; } + public AudibleSeriesId(string id) + { + ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id)); + Id = id; + } + } + public class Series + { + internal int SeriesId { get; private set; } + public string AudibleSeriesId { get; private set; } + + /// optional + public string Name { get; private set; } + + private HashSet _booksLink; + public IEnumerable BooksLink + => _booksLink? + .OrderBy(sb => sb.Index) + .ToList(); + + private Series() { } + /// special id class b/c it's too easy to get string order mixed up + public Series(AudibleSeriesId audibleSeriesId, string name = null) + { + ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId)); + var id = audibleSeriesId.Id; + ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id)); + AudibleSeriesId = id; + _booksLink = new HashSet(); + UpdateName(name); + } + + public void UpdateName(string name) + { + // don't overwrite with null or whitespace but not an error + if (!string.IsNullOrWhiteSpace(name)) + Name = name; + } + + public void AddBook(Book book, float? index = null, DbContext context = null) + { + ArgumentValidator.EnsureNotNull(book, nameof(book)); + + // our add() is conditional upon what's already included in the collection. + // therefore if not loaded, a trip is required. might as well just load it + if (_booksLink == null) + { + ArgumentValidator.EnsureNotNull(context, nameof(context)); + if (!context.Entry(this).IsKeySet) + throw new InvalidOperationException("Could not add series"); + + context.Entry(this).Collection(s => s.BooksLink).Load(); + } + + if (_booksLink.SingleOrDefault(sb => sb.Book == book) == null) + _booksLink.Add(new SeriesBook(this, book, index)); + } + } +} diff --git a/DataLayer/UNTESTED/EfClasses/SeriesBook.cs b/DataLayer/UNTESTED/EfClasses/SeriesBook.cs new file mode 100644 index 00000000..907ad0ba --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/SeriesBook.cs @@ -0,0 +1,38 @@ +using Dinah.Core; + +namespace DataLayer +{ + public class SeriesBook + { + internal int SeriesId { get; private set; } + internal int BookId { get; private set; } + + /// + /// "index" not "order". This is both for sequence and display + /// Float allows for in-between books. eg: 2.5 + /// To show 2 editions as the same book in a series, give them the same index + /// null IS NOT the same as 0. Some series call a book "book 0" + /// + public float? Index { get; private set; } + + public Series Series { get; private set; } + public Book Book { get; private set; } + + private SeriesBook() { } + internal SeriesBook(Series series, Book book, float? index = null) + { + ArgumentValidator.EnsureNotNull(series, nameof(series)); + ArgumentValidator.EnsureNotNull(book, nameof(book)); + + Series = series; + Book = book; + Index = index; + } + + public void UpdateIndex(float? index) + { + if (index.HasValue) + Index = index.Value; + } + } +} diff --git a/DataLayer/UNTESTED/EfClasses/Supplement.cs b/DataLayer/UNTESTED/EfClasses/Supplement.cs new file mode 100644 index 00000000..3965c75f --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/Supplement.cs @@ -0,0 +1,24 @@ +using Dinah.Core; + +namespace DataLayer +{ + /// PDF/ZIP files only. Although book download info could be the same format, they're substantially different and subject to change + public class Supplement + { + internal int SupplementId { get; private set; } + internal int BookId { get; private set; } + + public Book Book { get; private set; } + public string Url { get; private set; } + + private Supplement() { } + public Supplement(Book book, string url) + { + ArgumentValidator.EnsureNotNull(book, nameof(book)); + ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url)); + + Book = book; + Url = url; + } + } +} diff --git a/DataLayer/UNTESTED/EfClasses/UserDefinedItem.cs b/DataLayer/UNTESTED/EfClasses/UserDefinedItem.cs new file mode 100644 index 00000000..0cb292e6 --- /dev/null +++ b/DataLayer/UNTESTED/EfClasses/UserDefinedItem.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Dinah.Core; + +namespace DataLayer +{ + public class UserDefinedItem + { + internal int BookId { get; private set; } + public Book Book { get; private set; } + + private UserDefinedItem() { } + internal UserDefinedItem(Book book) + { + ArgumentValidator.EnsureNotNull(book, nameof(book)); + Book = book; + } + + private string _tags = ""; + public string Tags + { + get => _tags; + set => _tags = sanitize(value); + } + #region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen + // only legal chars are letters numbers underscores and separating whitespace + // + // technically, the only char.s which aren't easily supported are \ [ ] + // however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character) + // it's easy to expand whitelist as needed + // for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates + // + // there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score + // full list of characters which must be escaped: + // + - && || ! ( ) { } [ ] ^ " ~ * ? : \ + static Regex regex = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled); + private static string sanitize(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return ""; + + var str = input + .Trim() + .ToLowerInvariant() + // assume a hyphen is supposed to be an underscore + .Replace("-", "_"); + + var unique = regex + // turn illegal characters into a space. this will also take care of turning new lines into spaces + .Replace(str, " ") + // split and remove excess spaces + .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) + // de-dup + .Distinct() + // this will prevent order from being relevant + .OrderBy(a => a); + + // currently, the string is the canonical set. if we later make the collection into the canonical set: + // var tags = new Hashset(list); // de-dup, order doesn't matter but can seem random due to hashing algo + // var isEqual = tagsNew.SetEquals(tagsOld); + + return string.Join(" ", unique); + } + + public IEnumerable TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries); + #endregion + + // owned: not an optional one-to-one + /// The user's individual book rating + public Rating Rating { get; private set; } = new Rating(0, 0, 0); + + public void UpdateRating(float overallRating, float performanceRating, float storyRating) + => Rating.Update(overallRating, performanceRating, storyRating); + } +} diff --git a/DataLayer/UNTESTED/LibationContext.cs b/DataLayer/UNTESTED/LibationContext.cs new file mode 100644 index 00000000..d677469d --- /dev/null +++ b/DataLayer/UNTESTED/LibationContext.cs @@ -0,0 +1,69 @@ +using DataLayer.Configurations; +using Dinah.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer +{ + public class LibationContext : InterceptableDbContext + { + // IMPORTANT: USING DbSet<> + // ======================== + // these run against the db. linq queries against these MUST be translatable to sql. primatives only. no POCOs or this error occurs: + // Unable to create a constant value of type 'DataLayer.Contributor'. Only primitive types or enumeration types are supported in this context. + // to use full object-linq, load and use local + // load full table: + // List contributors = ...; + // Contributors.Load(); + // Contributors.Local.Where(a => contributors.Contains(a)); + // load only those in object: + // // overwrite collection + // Entry(product).Collection(x => x.Narrators).Load(); + // product.Narrators = narrators; + public DbSet Library { get; private set; } + public DbSet Books { get; private set; } + public DbSet Contributors { get; private set; } + public DbSet Series { get; private set; } + public DbSet Categories { get; private set; } + + public static LibationContext Create() + { + var factory = new LibationContextFactory(); + var context = factory.Create(); + return context; + } + + // see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring() + internal LibationContext(DbContextOptions options) : base(options) { } + + // called on each instantiation + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + AddInterceptor(new TagPersistenceInterceptor()); + + base.OnConfiguring(optionsBuilder); + } + + // typically only called once per execution; NOT once per instantiation + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.ApplyConfiguration(new BookConfig()); + modelBuilder.ApplyConfiguration(new ContributorConfig()); + modelBuilder.ApplyConfiguration(new BookContributorConfig()); + modelBuilder.ApplyConfiguration(new SupplementConfig()); + modelBuilder.ApplyConfiguration(new UserDefinedItemConfig()); + modelBuilder.ApplyConfiguration(new LibraryBookConfig()); + modelBuilder.ApplyConfiguration(new SeriesConfig()); + modelBuilder.ApplyConfiguration(new SeriesBookConfig()); + modelBuilder.ApplyConfiguration(new CategoryConfig()); + + // seeds go here. examples in scratch pad + modelBuilder + .Entity() + .HasData(Category.GetEmpty()); + + // views are now supported via "query types" (instead of "entity types"): https://docs.microsoft.com/en-us/ef/core/modeling/query-types + } + } +} diff --git a/DataLayer/UNTESTED/LibationContextFactory.cs b/DataLayer/UNTESTED/LibationContextFactory.cs new file mode 100644 index 00000000..9f253569 --- /dev/null +++ b/DataLayer/UNTESTED/LibationContextFactory.cs @@ -0,0 +1,11 @@ +using Dinah.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer +{ + public class LibationContextFactory : DesignTimeDbContextFactoryBase + { + protected override LibationContext CreateNewInstance(DbContextOptions options) => new LibationContext(options); + protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlServer(connectionString); + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190114190811_Initial.Designer.cs b/DataLayer/UNTESTED/Migrations/20190114190811_Initial.Designer.cs new file mode 100644 index 00000000..d9c7a58a --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190114190811_Initial.Designer.cs @@ -0,0 +1,294 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20190114190811_Initial")] + partial class Initial + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.0-rtm-35687") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleProductId"); + + b.Property("CategoryId"); + + b.Property("DatePublished"); + + b.Property("Description"); + + b.Property("HasBookDetails"); + + b.Property("IsAbridged"); + + b.Property("LengthInMinutes"); + + b.Property("PictureId"); + + b.Property("Publisher"); + + b.Property("Title"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId"); + + b.Property("ContributorId"); + + b.Property("Role"); + + b.Property("Order"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleCategoryId"); + + b.Property("Name"); + + b.Property("ParentCategoryCategoryId"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.HasIndex("ParentCategoryCategoryId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleAuthorId"); + + b.Property("Name"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId"); + + b.Property("DateAdded"); + + b.Property("DownloadBookLink"); + + b.HasKey("BookId"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleSeriesId"); + + b.Property("Name"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId"); + + b.Property("BookId"); + + b.Property("Index"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.HasOne("DataLayer.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("BookId"); + + b1.Property("Url"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.HasOne("DataLayer.Book", "Book") + .WithMany("Supplements") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId"); + + b1.Property("Tags"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem"); + + b1.HasOne("DataLayer.Book", "Book") + .WithOne("UserDefinedItem") + .HasForeignKey("DataLayer.UserDefinedItem", "BookId") + .OnDelete(DeleteBehavior.Cascade); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId"); + + b2.Property("OverallRating"); + + b2.Property("PerformanceRating"); + + b2.Property("StoryRating"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.HasOne("DataLayer.UserDefinedItem") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "UserDefinedItemBookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("OverallRating"); + + b1.Property("PerformanceRating"); + + b1.Property("StoryRating"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.HasOne("DataLayer.Book") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.HasOne("DataLayer.Category", "ParentCategory") + .WithMany() + .HasForeignKey("ParentCategoryCategoryId"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190114190811_Initial.cs b/DataLayer/UNTESTED/Migrations/20190114190811_Initial.cs new file mode 100644 index 00000000..e088c6aa --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190114190811_Initial.cs @@ -0,0 +1,288 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace DataLayer.Migrations +{ + public partial class Initial : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Categories", + columns: table => new + { + CategoryId = table.Column(nullable: false) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + AudibleCategoryId = table.Column(nullable: true), + Name = table.Column(nullable: true), + ParentCategoryCategoryId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.CategoryId); + table.ForeignKey( + name: "FK_Categories_Categories_ParentCategoryCategoryId", + column: x => x.ParentCategoryCategoryId, + principalTable: "Categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Contributors", + columns: table => new + { + ContributorId = table.Column(nullable: false) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + Name = table.Column(nullable: true), + AudibleAuthorId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Contributors", x => x.ContributorId); + }); + + migrationBuilder.CreateTable( + name: "Series", + columns: table => new + { + SeriesId = table.Column(nullable: false) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + AudibleSeriesId = table.Column(nullable: true), + Name = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Series", x => x.SeriesId); + }); + + migrationBuilder.CreateTable( + name: "Books", + columns: table => new + { + BookId = table.Column(nullable: false) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + AudibleProductId = table.Column(nullable: true), + Title = table.Column(nullable: true), + Description = table.Column(nullable: true), + LengthInMinutes = table.Column(nullable: false), + PictureId = table.Column(nullable: true), + HasBookDetails = table.Column(nullable: false), + IsAbridged = table.Column(nullable: false), + Publisher = table.Column(nullable: true), + DatePublished = table.Column(nullable: true), + CategoryId = table.Column(nullable: false), + Rating_OverallRating = table.Column(nullable: false), + Rating_PerformanceRating = table.Column(nullable: false), + Rating_StoryRating = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Books", x => x.BookId); + table.ForeignKey( + name: "FK_Books_Categories_CategoryId", + column: x => x.CategoryId, + principalTable: "Categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BookContributor", + columns: table => new + { + BookId = table.Column(nullable: false), + ContributorId = table.Column(nullable: false), + Role = table.Column(nullable: false), + Order = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BookContributor", x => new { x.BookId, x.ContributorId, x.Role }); + table.ForeignKey( + name: "FK_BookContributor_Books_BookId", + column: x => x.BookId, + principalTable: "Books", + principalColumn: "BookId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_BookContributor_Contributors_ContributorId", + column: x => x.ContributorId, + principalTable: "Contributors", + principalColumn: "ContributorId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Library", + columns: table => new + { + BookId = table.Column(nullable: false), + DateAdded = table.Column(nullable: false), + DownloadBookLink = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Library", x => x.BookId); + table.ForeignKey( + name: "FK_Library_Books_BookId", + column: x => x.BookId, + principalTable: "Books", + principalColumn: "BookId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SeriesBook", + columns: table => new + { + SeriesId = table.Column(nullable: false), + BookId = table.Column(nullable: false), + Index = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SeriesBook", x => new { x.SeriesId, x.BookId }); + table.ForeignKey( + name: "FK_SeriesBook_Books_BookId", + column: x => x.BookId, + principalTable: "Books", + principalColumn: "BookId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SeriesBook_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "SeriesId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Supplement", + columns: table => new + { + SupplementId = table.Column(nullable: false) + .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), + BookId = table.Column(nullable: false), + Url = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Supplement", x => x.SupplementId); + table.ForeignKey( + name: "FK_Supplement_Books_BookId", + column: x => x.BookId, + principalTable: "Books", + principalColumn: "BookId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserDefinedItem", + columns: table => new + { + BookId = table.Column(nullable: false), + Tags = table.Column(nullable: true), + Rating_OverallRating = table.Column(nullable: false), + Rating_PerformanceRating = table.Column(nullable: false), + Rating_StoryRating = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserDefinedItem", x => x.BookId); + table.ForeignKey( + name: "FK_UserDefinedItem_Books_BookId", + column: x => x.BookId, + principalTable: "Books", + principalColumn: "BookId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BookContributor_BookId", + table: "BookContributor", + column: "BookId"); + + migrationBuilder.CreateIndex( + name: "IX_BookContributor_ContributorId", + table: "BookContributor", + column: "ContributorId"); + + migrationBuilder.CreateIndex( + name: "IX_Books_AudibleProductId", + table: "Books", + column: "AudibleProductId"); + + migrationBuilder.CreateIndex( + name: "IX_Books_CategoryId", + table: "Books", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Categories_AudibleCategoryId", + table: "Categories", + column: "AudibleCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Categories_ParentCategoryCategoryId", + table: "Categories", + column: "ParentCategoryCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Contributors_Name", + table: "Contributors", + column: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_Series_AudibleSeriesId", + table: "Series", + column: "AudibleSeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesBook_BookId", + table: "SeriesBook", + column: "BookId"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesBook_SeriesId", + table: "SeriesBook", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_Supplement_BookId", + table: "Supplement", + column: "BookId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BookContributor"); + + migrationBuilder.DropTable( + name: "Library"); + + migrationBuilder.DropTable( + name: "SeriesBook"); + + migrationBuilder.DropTable( + name: "Supplement"); + + migrationBuilder.DropTable( + name: "UserDefinedItem"); + + migrationBuilder.DropTable( + name: "Contributors"); + + migrationBuilder.DropTable( + name: "Series"); + + migrationBuilder.DropTable( + name: "Books"); + + migrationBuilder.DropTable( + name: "Categories"); + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190114191724_NullableCategory.Designer.cs b/DataLayer/UNTESTED/Migrations/20190114191724_NullableCategory.Designer.cs new file mode 100644 index 00000000..02ac3c43 --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190114191724_NullableCategory.Designer.cs @@ -0,0 +1,293 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20190114191724_NullableCategory")] + partial class NullableCategory + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.0-rtm-35687") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleProductId"); + + b.Property("CategoryId"); + + b.Property("DatePublished"); + + b.Property("Description"); + + b.Property("HasBookDetails"); + + b.Property("IsAbridged"); + + b.Property("LengthInMinutes"); + + b.Property("PictureId"); + + b.Property("Publisher"); + + b.Property("Title"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId"); + + b.Property("ContributorId"); + + b.Property("Role"); + + b.Property("Order"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleCategoryId"); + + b.Property("Name"); + + b.Property("ParentCategoryCategoryId"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.HasIndex("ParentCategoryCategoryId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleAuthorId"); + + b.Property("Name"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId"); + + b.Property("DateAdded"); + + b.Property("DownloadBookLink"); + + b.HasKey("BookId"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleSeriesId"); + + b.Property("Name"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId"); + + b.Property("BookId"); + + b.Property("Index"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.HasOne("DataLayer.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("BookId"); + + b1.Property("Url"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.HasOne("DataLayer.Book", "Book") + .WithMany("Supplements") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId"); + + b1.Property("Tags"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem"); + + b1.HasOne("DataLayer.Book", "Book") + .WithOne("UserDefinedItem") + .HasForeignKey("DataLayer.UserDefinedItem", "BookId") + .OnDelete(DeleteBehavior.Cascade); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId"); + + b2.Property("OverallRating"); + + b2.Property("PerformanceRating"); + + b2.Property("StoryRating"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.HasOne("DataLayer.UserDefinedItem") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "UserDefinedItemBookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("OverallRating"); + + b1.Property("PerformanceRating"); + + b1.Property("StoryRating"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.HasOne("DataLayer.Book") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.HasOne("DataLayer.Category", "ParentCategory") + .WithMany() + .HasForeignKey("ParentCategoryCategoryId"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190114191724_NullableCategory.cs b/DataLayer/UNTESTED/Migrations/20190114191724_NullableCategory.cs new file mode 100644 index 00000000..81da9611 --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190114191724_NullableCategory.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace DataLayer.Migrations +{ + public partial class NullableCategory : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Books_Categories_CategoryId", + table: "Books"); + + migrationBuilder.AlterColumn( + name: "CategoryId", + table: "Books", + nullable: true, + oldClrType: typeof(int)); + + migrationBuilder.AddForeignKey( + name: "FK_Books_Categories_CategoryId", + table: "Books", + column: "CategoryId", + principalTable: "Categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Books_Categories_CategoryId", + table: "Books"); + + migrationBuilder.AlterColumn( + name: "CategoryId", + table: "Books", + nullable: false, + oldClrType: typeof(int), + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_Books_Categories_CategoryId", + table: "Books", + column: "CategoryId", + principalTable: "Categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190124190012_NullCategory.Designer.cs b/DataLayer/UNTESTED/Migrations/20190124190012_NullCategory.Designer.cs new file mode 100644 index 00000000..c13a5ed2 --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190124190012_NullCategory.Designer.cs @@ -0,0 +1,293 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20190124190012_NullCategory")] + partial class NullCategory + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.0-rtm-35687") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleProductId"); + + b.Property("CategoryId"); + + b.Property("DatePublished"); + + b.Property("Description"); + + b.Property("HasBookDetails"); + + b.Property("IsAbridged"); + + b.Property("LengthInMinutes"); + + b.Property("PictureId"); + + b.Property("Publisher"); + + b.Property("Title"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId"); + + b.Property("ContributorId"); + + b.Property("Role"); + + b.Property("Order"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleCategoryId"); + + b.Property("Name"); + + b.Property("ParentCategoryCategoryId"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.HasIndex("ParentCategoryCategoryId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleAuthorId"); + + b.Property("Name"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId"); + + b.Property("DateAdded"); + + b.Property("DownloadBookLink"); + + b.HasKey("BookId"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleSeriesId"); + + b.Property("Name"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId"); + + b.Property("BookId"); + + b.Property("Index"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.HasOne("DataLayer.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId"); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("BookId"); + + b1.Property("Url"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.HasOne("DataLayer.Book", "Book") + .WithMany("Supplements") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId"); + + b1.Property("Tags"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem"); + + b1.HasOne("DataLayer.Book", "Book") + .WithOne("UserDefinedItem") + .HasForeignKey("DataLayer.UserDefinedItem", "BookId") + .OnDelete(DeleteBehavior.Cascade); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId"); + + b2.Property("OverallRating"); + + b2.Property("PerformanceRating"); + + b2.Property("StoryRating"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.HasOne("DataLayer.UserDefinedItem") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "UserDefinedItemBookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("OverallRating"); + + b1.Property("PerformanceRating"); + + b1.Property("StoryRating"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.HasOne("DataLayer.Book") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.HasOne("DataLayer.Category", "ParentCategory") + .WithMany() + .HasForeignKey("ParentCategoryCategoryId"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190124190012_NullCategory.cs b/DataLayer/UNTESTED/Migrations/20190124190012_NullCategory.cs new file mode 100644 index 00000000..c476140c --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190124190012_NullCategory.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace DataLayer.Migrations +{ + public partial class NullCategory : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190124190057_NonNullCategory.Designer.cs b/DataLayer/UNTESTED/Migrations/20190124190057_NonNullCategory.Designer.cs new file mode 100644 index 00000000..bd27fad5 --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190124190057_NonNullCategory.Designer.cs @@ -0,0 +1,294 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20190124190057_NonNullCategory")] + partial class NonNullCategory + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.0-rtm-35687") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleProductId"); + + b.Property("CategoryId"); + + b.Property("DatePublished"); + + b.Property("Description"); + + b.Property("HasBookDetails"); + + b.Property("IsAbridged"); + + b.Property("LengthInMinutes"); + + b.Property("PictureId"); + + b.Property("Publisher"); + + b.Property("Title"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId"); + + b.Property("ContributorId"); + + b.Property("Role"); + + b.Property("Order"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleCategoryId"); + + b.Property("Name"); + + b.Property("ParentCategoryCategoryId"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.HasIndex("ParentCategoryCategoryId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleAuthorId"); + + b.Property("Name"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId"); + + b.Property("DateAdded"); + + b.Property("DownloadBookLink"); + + b.HasKey("BookId"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleSeriesId"); + + b.Property("Name"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId"); + + b.Property("BookId"); + + b.Property("Index"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.HasOne("DataLayer.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("BookId"); + + b1.Property("Url"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.HasOne("DataLayer.Book", "Book") + .WithMany("Supplements") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId"); + + b1.Property("Tags"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem"); + + b1.HasOne("DataLayer.Book", "Book") + .WithOne("UserDefinedItem") + .HasForeignKey("DataLayer.UserDefinedItem", "BookId") + .OnDelete(DeleteBehavior.Cascade); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId"); + + b2.Property("OverallRating"); + + b2.Property("PerformanceRating"); + + b2.Property("StoryRating"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.HasOne("DataLayer.UserDefinedItem") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "UserDefinedItemBookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("OverallRating"); + + b1.Property("PerformanceRating"); + + b1.Property("StoryRating"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.HasOne("DataLayer.Book") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.HasOne("DataLayer.Category", "ParentCategory") + .WithMany() + .HasForeignKey("ParentCategoryCategoryId"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190124190057_NonNullCategory.cs b/DataLayer/UNTESTED/Migrations/20190124190057_NonNullCategory.cs new file mode 100644 index 00000000..4a53466b --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190124190057_NonNullCategory.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace DataLayer.Migrations +{ + public partial class NonNullCategory : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Books_Categories_CategoryId", + table: "Books"); + + migrationBuilder.AlterColumn( + name: "CategoryId", + table: "Books", + nullable: false, + oldClrType: typeof(int), + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_Books_Categories_CategoryId", + table: "Books", + column: "CategoryId", + principalTable: "Categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Books_Categories_CategoryId", + table: "Books"); + + migrationBuilder.AlterColumn( + name: "CategoryId", + table: "Books", + nullable: true, + oldClrType: typeof(int)); + + migrationBuilder.AddForeignKey( + name: "FK_Books_Categories_CategoryId", + table: "Books", + column: "CategoryId", + principalTable: "Categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190124192324_EmptyCategorySeed.Designer.cs b/DataLayer/UNTESTED/Migrations/20190124192324_EmptyCategorySeed.Designer.cs new file mode 100644 index 00000000..11cee740 --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190124192324_EmptyCategorySeed.Designer.cs @@ -0,0 +1,302 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20190124192324_EmptyCategorySeed")] + partial class EmptyCategorySeed + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.0-rtm-35687") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleProductId"); + + b.Property("CategoryId"); + + b.Property("DatePublished"); + + b.Property("Description"); + + b.Property("HasBookDetails"); + + b.Property("IsAbridged"); + + b.Property("LengthInMinutes"); + + b.Property("PictureId"); + + b.Property("Publisher"); + + b.Property("Title"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId"); + + b.Property("ContributorId"); + + b.Property("Role"); + + b.Property("Order"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleCategoryId"); + + b.Property("Name"); + + b.Property("ParentCategoryCategoryId"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.HasIndex("ParentCategoryCategoryId"); + + b.ToTable("Categories"); + + b.HasData( + new + { + CategoryId = -1, + AudibleCategoryId = "", + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleAuthorId"); + + b.Property("Name"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId"); + + b.Property("DateAdded"); + + b.Property("DownloadBookLink"); + + b.HasKey("BookId"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleSeriesId"); + + b.Property("Name"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId"); + + b.Property("BookId"); + + b.Property("Index"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.HasOne("DataLayer.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("BookId"); + + b1.Property("Url"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.HasOne("DataLayer.Book", "Book") + .WithMany("Supplements") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId"); + + b1.Property("Tags"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem"); + + b1.HasOne("DataLayer.Book", "Book") + .WithOne("UserDefinedItem") + .HasForeignKey("DataLayer.UserDefinedItem", "BookId") + .OnDelete(DeleteBehavior.Cascade); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId"); + + b2.Property("OverallRating"); + + b2.Property("PerformanceRating"); + + b2.Property("StoryRating"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.HasOne("DataLayer.UserDefinedItem") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "UserDefinedItemBookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("OverallRating"); + + b1.Property("PerformanceRating"); + + b1.Property("StoryRating"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.HasOne("DataLayer.Book") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.HasOne("DataLayer.Category", "ParentCategory") + .WithMany() + .HasForeignKey("ParentCategoryCategoryId"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190124192324_EmptyCategorySeed.cs b/DataLayer/UNTESTED/Migrations/20190124192324_EmptyCategorySeed.cs new file mode 100644 index 00000000..ab7495d1 --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190124192324_EmptyCategorySeed.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace DataLayer.Migrations +{ + public partial class EmptyCategorySeed : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "Categories", + columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" }, + values: new object[] { -1, "", "", null }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "CategoryId", + keyValue: -1); + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190124214317_PublisherContrib.Designer.cs b/DataLayer/UNTESTED/Migrations/20190124214317_PublisherContrib.Designer.cs new file mode 100644 index 00000000..396a854b --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190124214317_PublisherContrib.Designer.cs @@ -0,0 +1,300 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20190124214317_PublisherContrib")] + partial class PublisherContrib + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.0-rtm-35687") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleProductId"); + + b.Property("CategoryId"); + + b.Property("DatePublished"); + + b.Property("Description"); + + b.Property("HasBookDetails"); + + b.Property("IsAbridged"); + + b.Property("LengthInMinutes"); + + b.Property("PictureId"); + + b.Property("Title"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId"); + + b.Property("ContributorId"); + + b.Property("Role"); + + b.Property("Order"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleCategoryId"); + + b.Property("Name"); + + b.Property("ParentCategoryCategoryId"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.HasIndex("ParentCategoryCategoryId"); + + b.ToTable("Categories"); + + b.HasData( + new + { + CategoryId = -1, + AudibleCategoryId = "", + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleAuthorId"); + + b.Property("Name"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId"); + + b.Property("DateAdded"); + + b.Property("DownloadBookLink"); + + b.HasKey("BookId"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleSeriesId"); + + b.Property("Name"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId"); + + b.Property("BookId"); + + b.Property("Index"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.HasOne("DataLayer.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("BookId"); + + b1.Property("Url"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.HasOne("DataLayer.Book", "Book") + .WithMany("Supplements") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId"); + + b1.Property("Tags"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem"); + + b1.HasOne("DataLayer.Book", "Book") + .WithOne("UserDefinedItem") + .HasForeignKey("DataLayer.UserDefinedItem", "BookId") + .OnDelete(DeleteBehavior.Cascade); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId"); + + b2.Property("OverallRating"); + + b2.Property("PerformanceRating"); + + b2.Property("StoryRating"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.HasOne("DataLayer.UserDefinedItem") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "UserDefinedItemBookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("OverallRating"); + + b1.Property("PerformanceRating"); + + b1.Property("StoryRating"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.HasOne("DataLayer.Book") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.HasOne("DataLayer.Category", "ParentCategory") + .WithMany() + .HasForeignKey("ParentCategoryCategoryId"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/20190124214317_PublisherContrib.cs b/DataLayer/UNTESTED/Migrations/20190124214317_PublisherContrib.cs new file mode 100644 index 00000000..123a55d3 --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/20190124214317_PublisherContrib.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace DataLayer.Migrations +{ + public partial class PublisherContrib : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Publisher", + table: "Books"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Publisher", + table: "Books", + nullable: true); + } + } +} diff --git a/DataLayer/UNTESTED/Migrations/LibationContextModelSnapshot.cs b/DataLayer/UNTESTED/Migrations/LibationContextModelSnapshot.cs new file mode 100644 index 00000000..0f0bf3aa --- /dev/null +++ b/DataLayer/UNTESTED/Migrations/LibationContextModelSnapshot.cs @@ -0,0 +1,298 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + partial class LibationContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.0-rtm-35687") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleProductId"); + + b.Property("CategoryId"); + + b.Property("DatePublished"); + + b.Property("Description"); + + b.Property("HasBookDetails"); + + b.Property("IsAbridged"); + + b.Property("LengthInMinutes"); + + b.Property("PictureId"); + + b.Property("Title"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId"); + + b.Property("ContributorId"); + + b.Property("Role"); + + b.Property("Order"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleCategoryId"); + + b.Property("Name"); + + b.Property("ParentCategoryCategoryId"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.HasIndex("ParentCategoryCategoryId"); + + b.ToTable("Categories"); + + b.HasData( + new + { + CategoryId = -1, + AudibleCategoryId = "", + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleAuthorId"); + + b.Property("Name"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId"); + + b.Property("DateAdded"); + + b.Property("DownloadBookLink"); + + b.HasKey("BookId"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AudibleSeriesId"); + + b.Property("Name"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId"); + + b.Property("BookId"); + + b.Property("Index"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.HasOne("DataLayer.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("BookId"); + + b1.Property("Url"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.HasOne("DataLayer.Book", "Book") + .WithMany("Supplements") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId"); + + b1.Property("Tags"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem"); + + b1.HasOne("DataLayer.Book", "Book") + .WithOne("UserDefinedItem") + .HasForeignKey("DataLayer.UserDefinedItem", "BookId") + .OnDelete(DeleteBehavior.Cascade); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId"); + + b2.Property("OverallRating"); + + b2.Property("PerformanceRating"); + + b2.Property("StoryRating"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.HasOne("DataLayer.UserDefinedItem") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "UserDefinedItemBookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .ValueGeneratedOnAdd() + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b1.Property("OverallRating"); + + b1.Property("PerformanceRating"); + + b1.Property("StoryRating"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.HasOne("DataLayer.Book") + .WithOne("Rating") + .HasForeignKey("DataLayer.Rating", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.HasOne("DataLayer.Category", "ParentCategory") + .WithMany() + .HasForeignKey("ParentCategoryCategoryId"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DataLayer/UNTESTED/QueryObjects/BookQueries.cs b/DataLayer/UNTESTED/QueryObjects/BookQueries.cs new file mode 100644 index 00000000..9a4919b5 --- /dev/null +++ b/DataLayer/UNTESTED/QueryObjects/BookQueries.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer +{ + public static class BookQueries + { + public static int BooksWithoutDetailsCount() + { + using (var context = LibationContext.Create()) + return context + .Books + .Count(b => !b.HasBookDetails); + } + + public static Book GetBook_Flat_NoTracking(string productId) + { + using (var context = LibationContext.Create()) + return context + .Books + .AsNoTracking() + .GetBook(productId); + } + + public static Book GetBook(this IQueryable books, string productId) + => books + .GetBooks() + .SingleOrDefault(b => b.AudibleProductId == productId); + + /// This is still IQueryable. YOU MUST CALL ToList() YOURSELF + public static IQueryable GetBooks(this IQueryable books, Expression> predicate) + => books + .GetBooks() + .Where(predicate); + + public static IQueryable GetBooks(this IQueryable books) + => books + // owned items are always loaded. eg: book.UserDefinedItem, book.Supplements + .Include(b => b.SeriesLink).ThenInclude(sb => sb.Series) + .Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor) + .Include(b => b.Category).ThenInclude(c => c.ParentCategory); + } +} diff --git a/DataLayer/UNTESTED/QueryObjects/GenericPaging.cs b/DataLayer/UNTESTED/QueryObjects/GenericPaging.cs new file mode 100644 index 00000000..389ec491 --- /dev/null +++ b/DataLayer/UNTESTED/QueryObjects/GenericPaging.cs @@ -0,0 +1,19 @@ +using System; +using System.Linq; + +namespace DataLayer +{ + public static class GenericPaging + { + public static IQueryable Page(this IQueryable query, int pageNumZeroStart, int pageSize) + { + if (pageSize < 1) + throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be at least 1"); + + if (pageNumZeroStart > 0) + query = query.Skip(pageNumZeroStart * pageSize); + + return query.Take(pageSize); + } + } +} \ No newline at end of file diff --git a/DataLayer/UNTESTED/QueryObjects/LibraryQueries.cs b/DataLayer/UNTESTED/QueryObjects/LibraryQueries.cs new file mode 100644 index 00000000..a0e6cde6 --- /dev/null +++ b/DataLayer/UNTESTED/QueryObjects/LibraryQueries.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer +{ + public static class LibraryQueries + { + public static List GetLibrary_Flat_NoTracking() + { + using (var context = LibationContext.Create()) + return context + .Library + .AsNoTracking() + .GetLibrary() + .ToList(); + } + + public static LibraryBook GetLibraryBook_Flat_NoTracking(string productId) + { + using (var context = LibationContext.Create()) + return context + .Library + .AsNoTracking() + .GetLibraryBook(productId); + } + + /// This is still IQueryable. YOU MUST CALL ToList() YOURSELF + public static IQueryable GetLibrary(this IQueryable library) + => library + // owned items are always loaded. eg: book.UserDefinedItem, book.Supplements + .Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series) + .Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor) + .Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory); + + public static LibraryBook GetLibraryBook(this IQueryable library, string productId) + => library + .GetLibrary() + .SingleOrDefault(le => le.Book.AudibleProductId == productId); + } +} diff --git a/DataLayer/UNTESTED/TagPersistenceInterceptor.cs b/DataLayer/UNTESTED/TagPersistenceInterceptor.cs new file mode 100644 index 00000000..7a83fc08 --- /dev/null +++ b/DataLayer/UNTESTED/TagPersistenceInterceptor.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dinah.Core.Collections.Generic; +using Dinah.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer +{ + internal class TagPersistenceInterceptor : IDbInterceptor + { + public void Executing(DbContext context) + { + doWork__EFCore(context); + } + + public void Executed(DbContext context) { } + + static void doWork__EFCore(DbContext context) + { + // persist tags: + var modifiedEntities = context.ChangeTracker.Entries().Where(p => p.State.In(EntityState.Modified, EntityState.Added)).ToList(); + var tagSets = modifiedEntities.Select(e => e.Entity as UserDefinedItem).Where(a => a != null).ToList(); + foreach (var t in tagSets) + FileManager.TagsPersistence.Save(t.Book.AudibleProductId, t.Tags); + } + + #region // notes: working with proxies, esp EF 6 + // EF 6: entities are proxied with lazy loading when collections are virtual + // EF Core: lazy loading is supported in 2.1 (there is a version of lazy loading with proxy-wrapping and a proxy-less version with DI) but not on by default and are not supported here + + //static void doWork_EF6(DbContext context) + //{ + // var modifiedEntities = context.ChangeTracker.Entries().Where(p => p.State == EntityState.Modified).ToList(); + // var unproxiedEntities = modifiedEntities.Select(me => UnProxy(context, me.Entity)).ToList(); + + // // persist tags + // var tagSets = unproxiedEntities.Select(ue => ue as UserDefinedItem).Where(a => a != null).ToList(); + // foreach (var t in tagSets) + // FileManager.TagsPersistence.Save(t.ProductId, t.TagsRaw); + //} + + //// https://stackoverflow.com/a/25774651 + //private static T UnProxy(DbContext context, T proxyObject) where T : class + //{ + // // alternative: https://docs.microsoft.com/en-us/ef/ef6/fundamentals/proxies + // var proxyCreationEnabled = context.Configuration.ProxyCreationEnabled; + // try + // { + // context.Configuration.ProxyCreationEnabled = false; + // return context.Entry(proxyObject).CurrentValues.ToObject() as T; + // } + // finally + // { + // context.Configuration.ProxyCreationEnabled = proxyCreationEnabled; + // } + //} + #endregion + } +} diff --git a/DataLayer/UNTESTED/_scratch pad/ScratchPad.cs b/DataLayer/UNTESTED/_scratch pad/ScratchPad.cs new file mode 100644 index 00000000..eee3f895 --- /dev/null +++ b/DataLayer/UNTESTED/_scratch pad/ScratchPad.cs @@ -0,0 +1,124 @@ +using System; +using Dinah.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace _scratch_pad +{ + ////// to use this as a console, open properties and change from class library => console + //// DON'T FORGET TO REVERT IT + //public class Program + //{ + // public static void Main(string[] args) + // { + // var user = new Student() { Name = "Dinah Cheshire" }; + // var udi = new UserDef { UserDefId = 1, TagsRaw = "my,tags" }; + + // using (var context = new MyTestContextDesignTimeDbContextFactory().Create()) + // { + // context.Add(user); + // //context.Add(udi); + // context.Update(udi); + // context.SaveChanges(); + // } + + // Console.WriteLine($"Student was saved in the database with id: {user.Id}"); + // } + //} + + public class MyTestContextDesignTimeDbContextFactory : DesignTimeDbContextFactoryBase + { + protected override MyTestContext CreateNewInstance(DbContextOptions options) => new MyTestContext(options); + protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString); + } + + public class MyTestContext : DbContext + { + // see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring() + public MyTestContext(DbContextOptions options) : base(options) { } + + #region classes for OnModelCreating() seed example + class Blog + { + public int BlogId { get; set; } + public string Url { get; set; } + public System.Collections.Generic.ICollection Posts { get; set; } + } + class Post + { + public int PostId { get; set; } + public string Content { get; set; } + public string Title { get; set; } + public int BlogId { get; set; } + public Blog Blog { get; set; } + public Name AuthorName { get; set; } + } + class Name + { + public string First { get; set; } + public string Last { get; set; } + } + #endregion + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // config + modelBuilder.Entity(entity => entity.Property(e => e.Url).IsRequired()); + modelBuilder.Entity().OwnsOne(p => p.OrderDetails, cb => + { + cb.OwnsOne(c => c.BillingAddress); + cb.OwnsOne(c => c.ShippingAddress); + }); + modelBuilder.Entity(entity => + entity + .HasOne(d => d.Blog) + .WithMany(p => p.Posts) + .HasForeignKey("BlogId")); + + // BlogSeed + modelBuilder.Entity().HasData(new Blog { BlogId = 1, Url = "http://sample.com" }); + + // PostSeed + modelBuilder.Entity().HasData(new Post() { BlogId = 1, PostId = 1, Title = "First post", Content = "Test 1" }); + + // AnonymousPostSeed + modelBuilder.Entity().HasData(new { BlogId = 1, PostId = 2, Title = "Second post", Content = "Test 2" }); + + // OwnedTypeSeed + modelBuilder.Entity().OwnsOne(p => p.AuthorName).HasData( + new { PostId = 1, First = "Andriy", Last = "Svyryd" }, + new { PostId = 2, First = "Diego", Last = "Vega" }); + } + + public DbSet Students { get; set; } + public DbSet UserDefs { get; set; } + public DbSet Orders { get; set; } + } + + public class Student + { + public int Id { get; set; } + public string Name { get; set; } + } + public class UserDef + { + public int UserDefId { get; set; } + public string TagsRaw { get; set; } + } + + public class Order + { + public int Id { get; set; } + public OrderDetails OrderDetails { get; set; } + } + public class OrderDetails + { + public StreetAddress BillingAddress { get; set; } + public StreetAddress ShippingAddress { get; set; } + } + public class StreetAddress + { + public string Street { get; set; } + public string City { get; set; } + } +} diff --git a/DataLayer/_HowTo- EF Core.txt b/DataLayer/_HowTo- EF Core.txt new file mode 100644 index 00000000..576fad73 --- /dev/null +++ b/DataLayer/_HowTo- EF Core.txt @@ -0,0 +1,48 @@ +HOW TO CREATE: EF CORE PROJECT +============================== +easiest with .NET Core but there's also a work-around for .NET Standard +example is for sqlite but the same works with MsSql + + +nuget +Microsoft.EntityFrameworkCore.Tools (needed for using Package Manager Console) +Microsoft.EntityFrameworkCore.Sqlite + +MIGRATIONS require standard, not core +using standard instead of core. edit 3 things in csproj +1of3: pluralize xml TargetFramework tag to TargetFrameworks +2of2: TargetFrameworks from: netstandard2.0 +to: netcoreapp2.1;netstandard2.0 +3of3: add + + true + + +run. error +SQLite Error 1: 'no such table: Blogs'. + +set project "Set as StartUp Project" + +Tools >> Nuget Package Manager >> Package Manager Console +default project: Examples\SQLite_NETCore2_0 + +note: in EFCore, Enable-Migrations is no longer used. start with add-migration +PM> add-migration InitialCreate +PM> Update-Database + +if add-migration xyz throws and error, don't take the error msg at face value. try again with add-migration xyz -verbose + +new sqlite .db file created: Copy always/Copy if newer +or copy .db file to destination + +relative: + optionsBuilder.UseSqlite("Data Source=blogging.db"); +absolute (use fwd slashes): + optionsBuilder.UseSqlite("Data Source=C:/foo/bar/blogging.db"); + + +REFERENCE ARTICLES +------------------ +https://docs.microsoft.com/en-us/ef/core/get-started/netcore/new-db-sqlite +https://carlos.mendible.com/2016/07/11/step-by-step-dotnet-core-and-entity-framework-core/ +https://www.benday.com/2017/12/19/ef-core-2-0-migrations-without-hard-coded-connection-strings/ \ No newline at end of file diff --git a/DataLayer/_big db refactor.txt b/DataLayer/_big db refactor.txt new file mode 100644 index 00000000..84ff9d7b --- /dev/null +++ b/DataLayer/_big db refactor.txt @@ -0,0 +1,55 @@ +proposed extensible schema to generalize beyond audible + +problems +0) reeks of premature optimization +- i'm currently only doing audible audiobooks. this adds several layers of abstraction for the sake of possible expansion +- there's a good chance that supporting another platform may not conform to this schema, in which case i'd have done this for nothing. genres are one likely pain point +- libation is currently single-user. hopefully the below would suffice for adding users, but if i'm wrong it might be all pain and no gain +1) very thorough == very complex +2) there are some books which would still be difficult to taxonimize +- joy of cooking. has become more of a brand +- the bible. has different versions that aren't just editions +- dictionary. authored by a publisher +3) "books" vs "editions" is a confusing problem waiting to happen + +[AIPK=auto increm PK] + +(libation) users [AIPK id, name, join date] +audible users [AIPK id, AUDIBLE-PK username] +libation audible users [PK user id, PK audible user id -- cluster PK across all FKs] +- potential danger in multi-user environment. wouldn't want one libation user getting access to a different libation user's audible info +contributors [AIPK id, name]. prev people. incl publishers +audible authors [PK/FK contributor id, AUDIBLE-PK author id] +roles [AIPK id, name]. seeded: author, narrator, publisher. could expand (eg: translator, editor) without each needing a new table +books [AIPK id, title, desc] +book contributors [FK book id, FK contributor id, FK role id, order -- cluster PK across all FKs] +- likely only authors +editions [AIPK id, FK book id, title]. could expand to include year, is first edition, is abridged +- reasons for optional different title: "Ender's Game: Special 20th Anniversary Edition", "Harry Potter and the Sorcerer's Stone" vs "Harry Potter and the Philosopher's Stone" vs "Harry Potter y la piedra filosofal", "Midnight Riot" vs "Rivers of London" +edition contributors [FK edition id, FK contributor id, FK role id, order -- cluster PK across all FKs] +- likely everything except authors. eg narrators, publisher +audiobooks [PK/FK edition id, lengthInMinutes] +- could expand to other formats by adding other similar tables. eg: print with #pages and isbn, ebook with mb +audible origins [AIPK id, name]. seeded: library. detail. json. series +audible books [PK/FK edition id, AUDIBLE-PK product id, picture id, sku, 3 ratings, audible category id, audible origin id] +- could expand to other vendors by adding other similar tables +audible user ratings [PK/FK edition id, audible user id, 3 ratings] +audible supplements [AIPK id, FK edition id, download url] +- pdfs only. although book download info could be the same format, they're substantially different and subject to change +audible book downloads [PK/FK edition id, audible user id, bookdownloadlink] +pictures [AIPK id, FK edition id, filename (xyz.jpg -- not incl path)] +audible categories [AIPK id, AUDIBLE-PK category id, name, parent]. may only nest 1 deep +(libation) library [FK libation user id, FK edition id, date added -- cluster PK across all FKs] +(libation) user defined [FK libation user id, FK edition id, tagsRaw (, notes...) -- cluster PK across all FKs] +- there's no reason to restrict tags to library items, so don't combine/link this table with library +series [AIPK id, name] +audible series [FK series id, AUDIBLE-PK series id/asin, audible origin id] +- could also include a 'name' field for what audible calls this series +series books [FK series id, FK book id (NOT edition id), index -- cluster PK across all FKs] +- "index" not "order". display this number; don't just put in this sequence +- index is float instead of int to allow for in-between books. eg 2.5 +- if only using "editions" (ie: getting rid of the "books" table), to show 2 editions as the same book in a series, give them the same index +(libation) user shelves [AIPK id, FK libation user id, name, desc] +- custom shelf. similar to library but very different in philosophy. likely different in evolving details +(libation) shelf books [AIPK id, FK user shelf id, date added, order] +- technically, it's no violation to list a book more than once so use AIPK diff --git a/DataLayer/_schema and patterns.txt b/DataLayer/_schema and patterns.txt new file mode 100644 index 00000000..c83abea8 --- /dev/null +++ b/DataLayer/_schema and patterns.txt @@ -0,0 +1,76 @@ +ignore for now: +authorProperties [PK/FK contributor id, AUDIBLE-PK author id] + notes in Contributor.cs for later refactoring + +c# enum only, not their own tables: +roles [AIPK id, name]. seeded: author, narrator, publisher. could expand (eg: translator, editor) without each needing a new table +origins [AIPK id, name]. seeded: library. detail. json. series + + +-- begin SCHEMA --------------------------------------------------------------------------------------------------------------------- +any audible keys should be indexed + +SCHEMA +====== +contributors [AIPK id, name]. people and publishers +books [AIPK id, AUDIBLE-PK product id, title, desc, lengthInMinutes, picture id, 3 ratings, category id, origin id] +- product instances. each edition and version is discrete: unique and disconnected from different editions of the same book +- on book re-import + update: + update book origin and series origin with the new source type + overwrite simple fields + invoke complex contributor updates + details page gets + un/abridged + release date + language + publisher + series info incl name + categories + if new == series: ignore. do update series info. do not update book info + else if old == json: update (incl if new == json) + else if old == library && new == detail: update + else: ignore +book contributors [FK book id, FK contributor id, FK role id, order -- cluster PK across all FKs] +supplements [AIPK id, FK book id, download url] +categories [AIPK id, AUDIBLE-PK category id, name, parent]. may only nest 1 deep +user defined [PK/FK book id, 3 ratings, tagsRaw] +series [AIPK id, AUDIBLE-PK series id/asin, name, origin id] +series books [FK series id, FK book id, index -- cluster PK across all FKs] +- "index" not "order". display this number; don't just put in this sequence +- index is float instead of int to allow for in-between books. eg 2.5 +- to show 2 editions as the same book in a series, give them the same index +- re-import using series page, there will need to be a re-eval of import logic +library [PK/FK book id, date added, bookdownloadlink] +-- end SCHEMA --------------------------------------------------------------------------------------------------------------------- + +-- begin SIMPLIFIED DDD --------------------------------------------------------------------------------------------------------------------- +combine domain and persistence (C(r)UD). no repository pattern. encapsulated in domain objects; direct calls to EF Core +https://www.thereformedprogrammer.net/creating-domain-driven-design-entity-classes-with-entity-framework-core/ + // pattern for x-to-many + public void AddReview(int numStars, DbContext context = null) + { + if (_reviews != null) _reviews.Add(new Review(numStars)); + else if (context == null) throw new Exception("need context"); + else if (context.Entry(this).IsKeySet) context.Add(new Review(numStars, BookId)); + else throw new Exception("Could not add"); + } + + // pattern for optional one-to-one + MyPropClass MyProps { get; private set; } + public void AddMyProps(string s, int i, DbContext context = null) + { + // avoid a trip to the db + if (MyProps != null) { MyProps.Update(s, i); return; } + if (BookId == 0) { MyProps = new MyPropClass(s, i); return; } + if (context == null) throw new Exception("need context"); + // per Jon P Smith, this single trip to db loads the property if there is one + // note: .Reference() is for single object references. for collections use .Collection() + context.Entry(this).Reference(s => s.MyProps).Load(); + if (MyProps != null) MyProps.Update(s, i); + else MyProps = new MyPropClass(s, i); + } + +repository reads are 'query object'-like extension methods +https://www.thereformedprogrammer.net/is-the-repository-pattern-useful-with-entity-framework-core/#1-query-objects-a-way-to-isolate-and-hide-database-read-code +-- and SIMPLIFIED DDD --------------------------------------------------------------------------------------------------------------------- \ No newline at end of file diff --git a/DataLayer/appsettings.json b/DataLayer/appsettings.json new file mode 100644 index 00000000..51c2ab56 --- /dev/null +++ b/DataLayer/appsettings.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "LibationContext": "Server=(LocalDb)\\MSSQLLocalDB;Database=DataLayer.LibationContext;Integrated Security=true;", + + "// on windows sqlite paths accept windows and/or unix slashes": "", + "MyTestContext": "Data Source=%DESKTOP%/sample.db" + } +} \ No newline at end of file diff --git a/DomainServices/DomainServices.csproj b/DomainServices/DomainServices.csproj new file mode 100644 index 00000000..66a40729 --- /dev/null +++ b/DomainServices/DomainServices.csproj @@ -0,0 +1,13 @@ + + + + netstandard2.1 + + + + + + + + + diff --git a/DomainServices/UNTESTED/BackupBook.cs b/DomainServices/UNTESTED/BackupBook.cs new file mode 100644 index 00000000..058fb8f6 --- /dev/null +++ b/DomainServices/UNTESTED/BackupBook.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using DataLayer; +using Dinah.Core.ErrorHandling; +using FileManager; + +namespace DomainServices +{ + /// + /// Download DRM book and decrypt audiobook files. + /// + /// Processes: + /// Download: download aax file: the DRM encrypted audiobook + /// Decrypt: remove DRM encryption from audiobook. Store final book + /// Backup: perform all steps (downloaded, decrypt) still needed to get final book + /// + public class BackupBook : IProcessable + { + public event EventHandler Begin; + public event EventHandler StatusUpdate; + public event EventHandler Completed; + + public DownloadBook Download { get; } = new DownloadBook(); + public DecryptBook Decrypt { get; } = new DecryptBook(); + + // ValidateAsync() doesn't need UI context + public async Task ValidateAsync(LibraryBook libraryBook) + => await validateAsync_ConfigureAwaitFalse(libraryBook.Book.AudibleProductId).ConfigureAwait(false); + private async Task validateAsync_ConfigureAwaitFalse(string productId) + => !await AudibleFileStorage.Audio.ExistsAsync(productId); + + // do NOT use ConfigureAwait(false) on ProcessUnregistered() + // often does a lot with forms in the UI context + public async Task ProcessAsync(LibraryBook libraryBook) + { + var displayMessage = $"[{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}"; + + Begin?.Invoke(this, displayMessage); + + try + { + var aaxExists = await AudibleFileStorage.AAX.ExistsAsync(libraryBook.Book.AudibleProductId); + if (!aaxExists) + await Download.ProcessAsync(libraryBook); + + return await Decrypt.ProcessAsync(libraryBook); + } + finally + { + Completed?.Invoke(this, displayMessage); + } + } + } +} diff --git a/DomainServices/UNTESTED/DecryptBook.cs b/DomainServices/UNTESTED/DecryptBook.cs new file mode 100644 index 00000000..97b31ecd --- /dev/null +++ b/DomainServices/UNTESTED/DecryptBook.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AaxDecrypter; +using DataLayer; +using Dinah.Core; +using Dinah.Core.ErrorHandling; +using FileManager; + +namespace DomainServices +{ + /// + /// Download DRM book and decrypt audiobook files. + /// + /// Processes: + /// Download: download aax file: the DRM encrypted audiobook + /// Decrypt: remove DRM encryption from audiobook. Store final book + /// Backup: perform all steps (downloaded, decrypt) still needed to get final book + /// + public class DecryptBook : IDecryptable + { + public event EventHandler Begin; + public event EventHandler StatusUpdate; + public event EventHandler DecryptBegin; + + public event EventHandler TitleDiscovered; + public event EventHandler AuthorsDiscovered; + public event EventHandler NarratorsDiscovered; + public event EventHandler CoverImageFilepathDiscovered; + public event EventHandler UpdateProgress; + + public event EventHandler DecryptCompleted; + public event EventHandler Completed; + + // ValidateAsync() doesn't need UI context + public async Task ValidateAsync(LibraryBook libraryBook) + => await validateAsync_ConfigureAwaitFalse(libraryBook.Book.AudibleProductId).ConfigureAwait(false); + private async Task validateAsync_ConfigureAwaitFalse(string productId) + => await AudibleFileStorage.AAX.ExistsAsync(productId) + && !(await AudibleFileStorage.Audio.ExistsAsync(productId)); + + // do NOT use ConfigureAwait(false) on ProcessUnregistered() + // often does a lot with forms in the UI context + public async Task ProcessAsync(LibraryBook libraryBook) + { + var displayMessage = $"[{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}"; + + Begin?.Invoke(this, displayMessage); + + try + { + var aaxFilename = await AudibleFileStorage.AAX.GetAsync(libraryBook.Book.AudibleProductId); + + if (aaxFilename == null) + return new StatusHandler { "aaxFilename parameter is null" }; + if (!FileUtility.FileExists(aaxFilename)) + return new StatusHandler { $"Cannot find AAX file: {aaxFilename}" }; + if (await AudibleFileStorage.Audio.ExistsAsync(libraryBook.Book.AudibleProductId)) + return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; + + string proposedOutputFile = Path.Combine(AudibleFileStorage.DecryptInProgress, $"[{libraryBook.Book.AudibleProductId}].m4b"); + + string outputAudioFilename; + //outputAudioFilename = await inAudibleDecrypt(proposedOutputFile, aaxFilename); + outputAudioFilename = await aaxToM4bConverterDecrypt(proposedOutputFile, aaxFilename); + + // decrypt failed + if (outputAudioFilename == null) + return new StatusHandler { "Decrypt failed" }; + + moveFilesToBooksDir(libraryBook.Book, outputAudioFilename); + + Dinah.Core.IO.FileExt.SafeDelete(aaxFilename); + + var statusHandler = new StatusHandler(); + var finalAudioExists = await AudibleFileStorage.Audio.ExistsAsync(libraryBook.Book.AudibleProductId); + if (!finalAudioExists) + statusHandler.AddError("Cannot find final audio file after decryption"); + return statusHandler; + } + finally + { + Completed?.Invoke(this, displayMessage); + } + } + + private async Task aaxToM4bConverterDecrypt(string proposedOutputFile, string aaxFilename) + { + DecryptBegin?.Invoke(this, $"Begin decrypting {aaxFilename}"); + + try + { + var converter = await AaxToM4bConverter.CreateAsync(aaxFilename, Configuration.Instance.DecryptKey); + converter.AppName = "Libation"; + + TitleDiscovered?.Invoke(this, converter.tags.title); + AuthorsDiscovered?.Invoke(this, converter.tags.author); + NarratorsDiscovered?.Invoke(this, converter.tags.narrator); + CoverImageFilepathDiscovered?.Invoke(this, converter.coverBytes); + + converter.SetOutputFilename(proposedOutputFile); + converter.DecryptProgressUpdate += (s, progress) => UpdateProgress?.Invoke(this, progress); + + // REAL WORK DONE HERE + var success = await Task.Run(() => converter.Run()); + + if (!success) + { + Console.WriteLine("decrypt failed"); + return null; + } + + Configuration.Instance.DecryptKey = converter.decryptKey; + + return converter.outputFileName; + } + finally + { + DecryptCompleted?.Invoke(this, $"Completed decrypting {aaxFilename}"); + } + } + + private static void moveFilesToBooksDir(Book product, string outputAudioFilename) + { + // files are: temp path\author\[asin].ext + var m4bDir = new FileInfo(outputAudioFilename).Directory; + var files = m4bDir + .EnumerateFiles() + .Where(f => f.Name.ContainsInsensitive(product.AudibleProductId)) + .ToList(); + + // create final directory. move each file into it. MOVE AUDIO FILE LAST + // new dir: safetitle_limit50char + " [" + productId + "]" + + // to prevent the paths from getting too long, we don't need after the 1st ":" for the folder + var underscoreIndex = product.Title.IndexOf(':'); + var titleDir = (underscoreIndex < 4) ? product.Title : product.Title.Substring(0, underscoreIndex); + var finalDir = FileUtility.GetValidFilename(AudibleFileStorage.BooksDirectory, titleDir, null, product.AudibleProductId); + Directory.CreateDirectory(finalDir); + + // move audio files to the end of the collection so these files are moved last + var musicFiles = files.Where(f => AudibleFileStorage.Audio.IsFileTypeMatch(f)); + files = files + .Except(musicFiles) + .Concat(musicFiles) + .ToList(); + + var musicFileExt = musicFiles + .Select(f => f.Extension) + .Distinct() + .Single() + .Trim('.'); + + foreach (var f in files) + { + var dest = AudibleFileStorage.Audio.IsFileTypeMatch(f) + // audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext + ? FileUtility.GetValidFilename(finalDir, product.Title, musicFileExt, product.AudibleProductId) + // non-audio filename: safetitle_limit50char + " [" + productId + "][" + audio_ext +"]." + non_audio_ext + : FileUtility.GetValidFilename(finalDir, product.Title, f.Extension, product.AudibleProductId, musicFileExt); + + File.Move(f.FullName, dest); + } + } + + #region legacy inAudible wire-up code + // + // instructions are in comments below for editing and interacting with inAudible. eg: + // \_NET\Visual Studio 2017\inAudible197\decompiled - in progress\inAudible.csproj + // first, add its project and put its exe path into inAudiblePath + // + #region placeholder code + // this exists so the below legacy code will compile as-is. comment out placeholder code when actually connecting to inAudible + + class Form + { + internal void Show() => throw new NotImplementedException(); + internal void Kill() => throw new NotImplementedException(); + } + class TextBox + { + internal string Text { set => throw new NotImplementedException(); } + } + class Button + { + internal void PerformClick() => throw new NotImplementedException(); + } + class AudibleConvertor + { + internal class GLOBALS + { + internal static string ExecutablePath { set => throw new NotImplementedException(); } + } + internal class Form1 : Form + { + internal Form1(Action action) => throw new NotImplementedException(); + internal void LoadAudibleFiles(string[] arr) => throw new NotImplementedException(); + internal TextBox txtOutputFile { get => throw new NotImplementedException(); } + internal Button btnConvert { get => throw new NotImplementedException(); } + } + } + #endregion + + private static string inAudiblePath { get; } + = @"C:\" + + @"DEV_ROOT_EXAMPLE\" + + @"_NET\Visual Studio 2017\" + + @"inAudible197\decompiled - in progress\bin\Debug\inAudible.exe"; + private static async Task inAudibleDecrypt(string proposedOutputFile, string aaxFilename) + { + #region // inAudible code to change: + /* + * Prevent "Path too long" error + * ============================= + * BatchFiles.cs :: GenerateOutputFilepath() + * Add this just before the bottom return statement + * + if (oneOff && !string.IsNullOrWhiteSpace(outputPath)) + return str + "\\" + Path.GetFileNameWithoutExtension(outputPath) + "." + fileType; + */ + #endregion + + #region init inAudible + #region // suppress warnings + // inAudible. project properties > Build > Warning level=2 + #endregion + #region // instructions to create inAudible ExecutablePath + /* + * STEP 1 + * ====== + * do a PROJECT level find/replace within inAudible + * find + * Application.ExecutablePath + * replace + * AudibleConvertor.GLOBALS.ExecutablePath + * STEP 2 + * ====== + * new inAudible root-level file + * _GLOBALS.cs + * contents: + * namespace AudibleConvertor { public static class GLOBALS { public static string ExecutablePath { get; set; } = System.Windows.Forms.Application.ExecutablePath; } } + */ + #endregion + AudibleConvertor.GLOBALS.ExecutablePath = inAudiblePath; + // before using inAudible, set ini values + setIniValues(new Dictionary { ["selected_codec"] = "lossless", ["embed_cover"] = "True", ["copy_cover_art"] = "False", ["create_cue"] = "True", ["nfo"] = "True", ["strip_unabridged"] = "True", }); + #endregion + + // this provides the async magic to keep all of the form calling code in one method instead of event callback pattern + // TODO: error handling is not obvious: + // https://deaddesk.top/don't-fall-for-TaskCompletionSource-traps/ + var tcs = new TaskCompletionSource(); + + // to know when inAudible is complete. code to change: + #region // code to preceed ctor + /* + Action _conversionCompleteAction; + public Form1(Action conversionCompleteAction) : this() => _conversionCompleteAction = conversionCompleteAction; + */ + #endregion + #region // code for the end of bgwAAX_Completed() + /* + if (this.myAdvancedOptions.beep && !this.myAdvancedOptions.cylon) this.SOXPlay(Form1.appPath + "\\beep.mp3", true); + else if (myAdvancedOptions.cylon) SOXPlay(appPath + "\\inAudible-end.mp3", true); + _conversionCompleteAction?.Invoke(outputFileName); + } + */ + #endregion + + #region start inAudible + var form = new AudibleConvertor.Form1(tcs.SetResult); + form.Show(); + form.LoadAudibleFiles(new string[] { aaxFilename }); // inAudible: make public + + // change output info to include asin. put in temp + form.txtOutputFile.Text = proposedOutputFile; // inAudible: make public + + // submit/process/decrypt + form.btnConvert.PerformClick(); // inAudible: make public + + // ta-da -- magic! we stop here until inAudible complete + var outputAudioFilename = await tcs.Task; + #endregion + + #region when complete, close inAudible + // use this instead of Dinah.Core.Windows.Forms.UIThread() + form.Kill(); + #endregion + + return outputAudioFilename; + } + + private static void setIniValues(Dictionary settings) + { + // C:\Users\username\Documents\inAudible\config.ini + var iniPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "inAudible", "config.ini"); + var iniContents = File.ReadAllText(iniPath); + + foreach (var kvp in settings) + iniContents = System.Text.RegularExpressions.Regex.Replace( + iniContents, + $@"\r\n{kvp.Key} = [^\r\n]+\r\n", + $"\r\n{kvp.Key} = {kvp.Value}\r\n"); + + File.WriteAllText(iniPath, iniContents); + } + #endregion + } +} diff --git a/DomainServices/UNTESTED/DownloadBook.cs b/DomainServices/UNTESTED/DownloadBook.cs new file mode 100644 index 00000000..2b4bc275 --- /dev/null +++ b/DomainServices/UNTESTED/DownloadBook.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using FileManager; +using DataLayer; +using Dinah.Core.ErrorHandling; + +namespace DomainServices +{ + /// + /// Download DRM book and decrypt audiobook files. + /// + /// Processes: + /// Download: download aax file: the DRM encrypted audiobook + /// Decrypt: remove DRM encryption from audiobook. Store final book + /// Backup: perform all steps (downloaded, decrypt) still needed to get final book + /// + public class DownloadBook : DownloadableBase + { + public override async Task ValidateAsync(LibraryBook libraryBook) + => !await AudibleFileStorage.Audio.ExistsAsync(libraryBook.Book.AudibleProductId) + && !await AudibleFileStorage.AAX.ExistsAsync(libraryBook.Book.AudibleProductId); + + public override async Task ProcessItemAsync(LibraryBook libraryBook) + { + var tempAaxFilename = FileUtility.GetValidFilename( + AudibleFileStorage.DownloadsInProgress, + libraryBook.Book.Title, + "aax", + libraryBook.Book.AudibleProductId); + + // if getting from full title: + // '?' is allowed + // colons are inconsistent but not problematic to just leave them + // - 1 colon: sometimes full title is used. sometimes only the part before the colon is used + // - multple colons: only the part before the final colon is used + // e.g. Alien: Out of the Shadows: An Audible Original Drama => Alien: Out of the Shadows + // in cases where title includes '&', just use everything before the '&' and ignore the rest + //// var adhTitle = product.Title.Split('&')[0] + + var aaxDownloadLink = libraryBook.DownloadBookLink + .Replace("/admhelper", "") + .Replace("&DownloadType=Now", "") + + "&asin=&source=audible_adm&size=&browser_type=&assemble_url=http://cds.audible.com/download"; + var uri = new Uri(aaxDownloadLink); + using (var webClient = await GetWebClient(tempAaxFilename)) + { + // for book downloads only: pretend to be the audible download manager. from inAudible: + webClient.Headers["User-Agent"] = "Audible ADM 6.6.0.15;Windows Vista Service Pack 1 Build 7601"; + + await webClient.DownloadFileTaskAsync(uri, tempAaxFilename); + } + + // move + var aaxFilename = FileUtility.GetValidFilename( + AudibleFileStorage.DownloadsFinal, + libraryBook.Book.Title, + "aax", + libraryBook.Book.AudibleProductId); + File.Move(tempAaxFilename, aaxFilename); + + var statusHandler = new StatusHandler(); + var isDownloaded = await AudibleFileStorage.AAX.ExistsAsync(libraryBook.Book.AudibleProductId); + if (isDownloaded) + DoStatusUpdate($"Downloaded: {aaxFilename}"); + else + statusHandler.AddError("Downloaded AAX file cannot be found"); + return statusHandler; + } + } +} diff --git a/DomainServices/UNTESTED/DownloadLibrary.cs b/DomainServices/UNTESTED/DownloadLibrary.cs new file mode 100644 index 00000000..7059d097 --- /dev/null +++ b/DomainServices/UNTESTED/DownloadLibrary.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using InternalUtilities; +using AudibleDotCom; +using AudibleDotComAutomation; +using FileManager; +using Scraping; + +namespace DomainServices +{ + public static class DownloadLibrary + { + /// scrape all library pages. save htm files. save json files + /// paths of json files + public static async Task> DownloadLibraryAsync(IPageRetriever pageRetriever) + { + var batchName = WebpageStorage.GetLibraryBatchName(); + + // library webpages => AudiblePageSource objects + var libraryAudiblePageSources = await pageRetriever.GetPageSourcesAsync(AudiblePageType.Library); + + var jsonFiles = new List(); + foreach (var libraryAudiblePageSource in libraryAudiblePageSources) + { + // good habit to persist htm before attempting to parse it. this way, if there's a parse error, we can test errors on a local copy + var htmFile = DataConverter.AudiblePageSource_2_HtmFile_Batch(libraryAudiblePageSource, batchName); + + var libraryDTOs = AudibleScraper.ScrapeLibrarySources(libraryAudiblePageSource); + var jsonFile = DataConverter.Value_2_JsonFile(libraryDTOs, Path.ChangeExtension(htmFile.FullName, "json")); + + jsonFiles.Add(jsonFile); + } + + return jsonFiles; + } + } +} diff --git a/DomainServices/UNTESTED/DownloadPdf.cs b/DomainServices/UNTESTED/DownloadPdf.cs new file mode 100644 index 00000000..1ff5b521 --- /dev/null +++ b/DomainServices/UNTESTED/DownloadPdf.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DataLayer; +using Dinah.Core.ErrorHandling; +using FileManager; + +namespace DomainServices +{ + public class DownloadPdf : DownloadableBase + { + public override async Task ValidateAsync(LibraryBook libraryBook) + { + var product = libraryBook.Book; + + if (!product.Supplements.Any()) + return false; + + return !await AudibleFileStorage.PDF.ExistsAsync(product.AudibleProductId); + } + + public override async Task ProcessItemAsync(LibraryBook libraryBook) + { + var product = libraryBook.Book; + + if (product == null) + return new StatusHandler { "Book not found" }; + + var urls = product.Supplements.Select(d => d.Url).ToList(); + if (urls.Count == 0) + return new StatusHandler { "PDF download url not found" }; + + // sanity check + if (urls.Count > 1) + throw new Exception("Multiple PDF downloads are not currently supported. typically indicates an error"); + + var url = urls.Single(); + + var destinationDir = await getDestinationDirectory(product.AudibleProductId); + if (destinationDir == null) + return new StatusHandler { "Destination directory not found for PDF download" }; + + var destinationFilename = Path.Combine(destinationDir, Path.GetFileName(url)); + + using (var webClient = await GetWebClient(destinationFilename)) + await webClient.DownloadFileTaskAsync(url, destinationFilename); + + var statusHandler = new StatusHandler(); + var exists = await AudibleFileStorage.PDF.ExistsAsync(product.AudibleProductId); + if (!exists) + statusHandler.AddError("Downloaded PDF cannot be found"); + return statusHandler; + } + + private async Task getDestinationDirectory(string productId) + { + // if audio file exists, get it's dir + var audioFile = await AudibleFileStorage.Audio.GetAsync(productId); + if (audioFile != null) + return Path.GetDirectoryName(audioFile); + + // else return base Book dir + return AudibleFileStorage.PDF.StorageDirectory; + } + } +} diff --git a/DomainServices/UNTESTED/DownloadableBase.cs b/DomainServices/UNTESTED/DownloadableBase.cs new file mode 100644 index 00000000..b7daa6c6 --- /dev/null +++ b/DomainServices/UNTESTED/DownloadableBase.cs @@ -0,0 +1,92 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using DataLayer; +using Dinah.Core.ErrorHandling; +using Dinah.Core.Humanizer; + +namespace DomainServices +{ + public abstract class DownloadableBase : IDownloadable + { + public event EventHandler Begin; + + public event EventHandler StatusUpdate; + protected void DoStatusUpdate(string message) => StatusUpdate?.Invoke(this, message); + + public event EventHandler DownloadBegin; + public event DownloadProgressChangedEventHandler DownloadProgressChanged; + public event EventHandler DownloadCompleted; + + public event EventHandler Completed; + + static DownloadableBase() + { + // https://stackoverflow.com/a/15483698 + ServicePointManager.ServerCertificateValidationCallback = delegate { return true; }; + } + + public abstract Task ValidateAsync(LibraryBook libraryBook); + + public abstract Task ProcessItemAsync(LibraryBook libraryBook); + + // do NOT use ConfigureAwait(false) on ProcessUnregistered() + // often does a lot with forms in the UI context + public async Task ProcessAsync(LibraryBook libraryBook) + { + var displayMessage = $"[{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}"; + + Begin?.Invoke(this, displayMessage); + + try + { + return await ProcessItemAsync(libraryBook); + } + finally + { + Completed?.Invoke(this, displayMessage); + } + } + + // other user agents from my chrome. from: https://www.whoishostingthis.com/tools/user-agent/ + static string[] userAgents { get; } = new[] + { + "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.96 Safari/537.36", + }; + // we need a minimum delay between tries when hitting audible.com + // in every case except decrypt (which is already long running), we hit audible.com + static Humanizer humanizer { get; } = new Humanizer { Minimum = 5, Maximum = 20 }; + static Random rnd = new Random(); + protected async Task GetWebClient(string downloadMessage) + { + await humanizer.Wait(); + + var webClient = new WebClient(); + + // https://towardsdatascience.com/5-strategies-to-write-unblock-able-web-scrapers-in-python-5e40c147bdaf + var userAgentIndex = rnd.Next(0, userAgents.Length); // upper bound is exclusive + webClient.Headers["User-Agent"] = userAgents[userAgentIndex]; + webClient.Headers["Referer"] = "https://google.com"; + webClient.Headers["Upgrade-Insecure-Requests"] = "1"; + webClient.Headers["DNT"] = "1"; + webClient.Headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"; + webClient.Headers["Accept-Language"] = "en-US,en;q=0.9"; + + // this breaks pdf download which uses: http://download.audible.com + // weirdly, it works for book download even though it uses https://cds.audible.com + //webClient.Headers["Host"] = "www.audible.com"; + + webClient.DownloadProgressChanged += (s, e) => DownloadProgressChanged?.Invoke(s, e); + webClient.DownloadFileCompleted += (s, e) => DownloadCompleted?.Invoke(s, $"Completed: {downloadMessage}"); + webClient.DownloadDataCompleted += (s, e) => DownloadCompleted?.Invoke(s, $"Completed: {downloadMessage}"); + webClient.DownloadStringCompleted += (s, e) => DownloadCompleted?.Invoke(s, $"Completed: {downloadMessage}"); + + DownloadBegin?.Invoke(this, downloadMessage); + + return webClient; + } + } +} diff --git a/DomainServices/UNTESTED/IDecryptable.cs b/DomainServices/UNTESTED/IDecryptable.cs new file mode 100644 index 00000000..8e3a0576 --- /dev/null +++ b/DomainServices/UNTESTED/IDecryptable.cs @@ -0,0 +1,17 @@ +using System; + +namespace DomainServices +{ + public interface IDecryptable : IProcessable + { + event EventHandler DecryptBegin; + + event EventHandler TitleDiscovered; + event EventHandler AuthorsDiscovered; + event EventHandler NarratorsDiscovered; + event EventHandler CoverImageFilepathDiscovered; + event EventHandler UpdateProgress; + + event EventHandler DecryptCompleted; + } +} diff --git a/DomainServices/UNTESTED/IDownloadable.cs b/DomainServices/UNTESTED/IDownloadable.cs new file mode 100644 index 00000000..a5c731fb --- /dev/null +++ b/DomainServices/UNTESTED/IDownloadable.cs @@ -0,0 +1,12 @@ +using System; +using System.Net; + +namespace DomainServices +{ + public interface IDownloadable : IProcessable + { + event EventHandler DownloadBegin; + event DownloadProgressChangedEventHandler DownloadProgressChanged; + event EventHandler DownloadCompleted; + } +} diff --git a/DomainServices/UNTESTED/IProcessable.cs b/DomainServices/UNTESTED/IProcessable.cs new file mode 100644 index 00000000..519e208f --- /dev/null +++ b/DomainServices/UNTESTED/IProcessable.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; +using DataLayer; +using Dinah.Core.ErrorHandling; + +namespace DomainServices +{ + public interface IProcessable + { + event EventHandler Begin; + + /// General string message to display. DON'T rely on this for success, failure, or control logic + event EventHandler StatusUpdate; + + event EventHandler Completed; + + /// True == Valid + Task ValidateAsync(LibraryBook libraryBook); + + /// True == success + Task ProcessAsync(LibraryBook libraryBook); + } +} diff --git a/DomainServices/UNTESTED/IProcessableExt.cs b/DomainServices/UNTESTED/IProcessableExt.cs new file mode 100644 index 00000000..fd4ae771 --- /dev/null +++ b/DomainServices/UNTESTED/IProcessableExt.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using DataLayer; +using Dinah.Core.ErrorHandling; + +namespace DomainServices +{ + public static class IProcessableExt + { + // + // DO NOT USE ConfigureAwait(false) WITH ProcessAsync() unless ensuring ProcessAsync() implementation is cross-thread compatible + // - ValidateAsync() doesn't need UI context. however, each class already uses ConfigureAwait(false) + // - ProcessAsync() often does a lot with forms in the UI context + // + + + /// Process the first valid product. Create default context + /// Returns either the status handler from the process, or null if all books have been processed + public static async Task ProcessFirstValidAsync(this IProcessable processable) + { + var libraryBook = await processable.GetNextValidAsync(); + if (libraryBook == null) + return null; + + var status = await processable.ProcessAsync(libraryBook); + if (status == null) + throw new Exception("Processable should never return a null status"); + + return status; + } + + // i'd love to turn this into Task> + // since enumeration is a blocking operation, this won't be possible until + // 2019's C# 8 async streams, aka async enumerables, aka async iterators: https://blogs.msdn.microsoft.com/dotnet/2018/11/12/building-c-8-0/ + public static async Task GetNextValidAsync(this IProcessable processable) + { + var libraryBooks = LibraryQueries.GetLibrary_Flat_NoTracking(); + + foreach (var libraryBook in libraryBooks) + if (await processable.ValidateAsync(libraryBook)) + return libraryBook; + + return null; + } + + public static async Task ProcessValidateLibraryBookAsync(this IProcessable processable, LibraryBook libraryBook) + { + if (!await processable.ValidateAsync(libraryBook)) + return new StatusHandler { "Validation failed" }; + return await processable.ProcessAsync(libraryBook); + } + } +} diff --git a/DomainServices/UNTESTED/Indexer.cs b/DomainServices/UNTESTED/Indexer.cs new file mode 100644 index 00000000..fa53fbce --- /dev/null +++ b/DomainServices/UNTESTED/Indexer.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using DataLayer; +using Dinah.Core; +using Dinah.EntityFrameworkCore; +using FileManager; +using InternalUtilities; +using Newtonsoft.Json; +using Scraping.BookDetail; +using Scraping.Library; + +namespace DomainServices +{ + public static class Indexer + { + #region library + public static async Task<(int total, int newEntries)> IndexLibraryAsync(DirectoryInfo libDir) + { + var jsonFileInfos = WebpageStorage.GetJsonFiles(libDir); + return await IndexLibraryAsync(jsonFileInfos); + } + + public static async Task<(int total, int newEntries)> IndexLibraryAsync(List jsonFileInfos) + { + var productItems = jsonFileInfos.SelectMany(fi => json2libraryDtos(fi)).ToList(); + return await IndexLibraryAsync(productItems); + } + private static Regex jsonIsCollectionRegex = new Regex(@"^\s*\[\s*\{", RegexOptions.Compiled); + private static IEnumerable json2libraryDtos(FileInfo jsonFileInfo) + { + validateJsonFile(jsonFileInfo); + + var serialized = File.ReadAllText(jsonFileInfo.FullName); + + // collection + if (jsonIsCollectionRegex.IsMatch(serialized)) + return JsonConvert.DeserializeObject>(serialized); + + // single + return new List { JsonConvert.DeserializeObject(serialized) }; + } + + // new full index or library-file import: re-create search index + public static async Task<(int total, int newEntries)> IndexLibraryAsync(List productItems) + => await IndexLibraryAsync(productItems, SearchEngineActions.FullReIndexAsync); + + private static async Task<(int total, int newEntries)> IndexLibraryAsync(List productItems, Func postIndexActionAsync) + { + if (productItems == null || !productItems.Any()) + return (0, 0); + + filterAndValidate(productItems); + + int newEntries; + using (var context = LibationContext.Create()) + { + var dtoImporter = new DtoImporter(context); + + #region // benchmarks. re-importing a library with 500 books, all with book details json files + /* + dtoImporter.ReplaceLibrary 1.2 seconds + SaveChanges() 3.4 + ReloadBookDetails() 1.3 + SaveChanges() 1.4 + */ + #endregion + // LONG RUNNING + newEntries = await Task.Run(() => dtoImporter.ReplaceLibrary(productItems)); + await context.SaveChangesAsync(); + + // must be broken out. see notes in dtoImporter.ReplaceLibrary() + // LONG RUNNING + await Task.Run(() => dtoImporter.ReloadBookDetails(productItems)); + await context.SaveChangesAsync(); + } + + await postIndexActionAsync?.Invoke(); + + return (productItems.Count, newEntries); + } + private static void filterAndValidate(List collection) + { + //debug//var episodes = collection.Where(dto => dto.IsEpisodes).ToList(); + + // for now, do not add episodic content + collection.RemoveAll(dto => dto.IsEpisodes); + + if (collection.Any(pi => string.IsNullOrWhiteSpace(pi.ProductId))) + throw new Exception("All product items must contain a Product Id"); + + var duplicateIds = collection + .GroupBy(pi => pi.ProductId) + .Where(grp => grp.Count() > 1) + .Select(grp => grp.Key); + + if (duplicateIds.Any()) + throw new Exception("Cannot insert multiples of the same ProductId. Duplicates:" + + duplicateIds + .Select(a => "\r\n- " + a) + .Aggregate((a, b) => a + b)); + } + #endregion + + #region update book tags + public static int IndexChangedTags(Book book) + { + // update disconnected entity + int qtyChanges; + using (var context = LibationContext.Create()) + { + context.Update(book); + qtyChanges = context.SaveChanges(); + } + + // this part is tags-specific + if (qtyChanges > 0) + SearchEngineActions.UpdateBookTags(book); + + return qtyChanges; + } + #endregion + + #region book details + public static async Task IndexBookDetailsAsync(FileInfo jsonFileInfo) + { + var bookDetailDTO = json2bookDetailDto(jsonFileInfo); + await IndexBookDetailsAsync(bookDetailDTO); + } + private static BookDetailDTO json2bookDetailDto(FileInfo jsonFileInfo) + { + validateJsonFile(jsonFileInfo); + + var serialized = File.ReadAllText(jsonFileInfo.FullName); + return JsonConvert.DeserializeObject(serialized); + } + + public static async Task IndexBookDetailsAsync(BookDetailDTO bookDetailDTO) + => await indexBookDetailsAsync(bookDetailDTO, () => SearchEngineActions.ProductReIndexAsync(bookDetailDTO.ProductId)); + + private static async Task indexBookDetailsAsync(BookDetailDTO bookDetailDTO, Func postIndexActionAsync) + { + if (bookDetailDTO == null) + return; + + validate(bookDetailDTO); + + using (var context = LibationContext.Create()) + { + var dtoImporter = new DtoImporter(context); + // LONG RUNNING + await Task.Run(() => dtoImporter.UpdateBookDetails(bookDetailDTO)); + context.SaveChanges(); + + // after saving, delete orphan contributors + var count = context.RemoveOrphans(); + if (count > 0) + { + } + } + + await postIndexActionAsync?.Invoke(); + } + private static void validate(BookDetailDTO bookDetailDTO) + { + if (string.IsNullOrWhiteSpace(bookDetailDTO.ProductId)) + throw new Exception("Product must contain a Product Id"); + } + #endregion + + private static void validateJsonFile(FileInfo jsonFileInfo) + { + if (!jsonFileInfo.Extension.EqualsInsensitive(".json")) + throw new Exception("Unsupported file types"); + } + } +} diff --git a/DomainServices/UNTESTED/ScrapeBookDetails.cs b/DomainServices/UNTESTED/ScrapeBookDetails.cs new file mode 100644 index 00000000..7b005e6b --- /dev/null +++ b/DomainServices/UNTESTED/ScrapeBookDetails.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using AudibleDotCom; +using DataLayer; +using Dinah.Core.ErrorHandling; +using InternalUtilities; +using Scraping; +using Scraping.BookDetail; + +namespace DomainServices +{ + /// + /// book detail page: + /// - audible webpage => AudiblePageSource + /// - AudiblePageSource => declaw => htm file + /// - AudiblePageSource => scrape => DTO + /// - DTO => json file + /// - DTO => db + /// - update lucene + /// + public class ScrapeBookDetails : DownloadableBase + { + public enum NoLongerAvailableEnum { None, Abort, MarkAsMissing } + + /// Returns product id of book which was successfully imported and re-indexed + public event EventHandler BookSuccessfullyImported; + + /// Hook for handling book no-longer-available. String 1: book title. String 2: book url + public Func NoLongerAvailableAction { get; set; } + + public override Task ValidateAsync(LibraryBook libraryBook) + => Task.FromResult(!libraryBook.Book.HasBookDetails); + + public override async Task ProcessItemAsync(LibraryBook libraryBook) + { + var productId = libraryBook.Book.AudibleProductId; + + #region // TEST CODE + //productId = "B0787DGS2T"; // book with only 1 category, no sub category + //productId = "B002V1OF70"; // mult series, more narrators here than in library + //productId = "B0032N8Q58"; // abridged + //productId = "B07GXW7KHG"; // categories in product details block. no narrators + //productId = "B002ZEEDAW"; // categores above image + //productId = "B075Y4SWJ8"; // lots of narrators, no 'abridged' + #endregion + + BookDetailDTO bookDetailDTO; + + // if json file exists, then htm is irrelevant. important b/c in cases of no-longer-available items, json is generated but no htm + var jsonFileInfo = FileManager.WebpageStorage.GetBookDetailJsonFileInfo(productId); + if (jsonFileInfo.Exists) + { + var serialized = File.ReadAllText(jsonFileInfo.FullName); + bookDetailDTO = Newtonsoft.Json.JsonConvert.DeserializeObject(serialized); + } + // no json. download htm + else + { + var htmFile = FileManager.WebpageStorage.GetBookDetailHtmFileInfo(productId); + // htm exists, json doesn't. load existing htm + if (htmFile.Exists) + { + var detailsAudiblePageSource = DataConverter.HtmFile_2_AudiblePageSource(htmFile.FullName); + bookDetailDTO = AudibleScraper.ScrapeBookDetailsSource(detailsAudiblePageSource); + } + // no htm. download and parse + else + { + // download htm + string source; + var url = AudiblePage.Product.GetUrl(productId); + using (var webClient = await GetWebClient($"Getting Book Details for {libraryBook.Book.Title}")) + { + try + { + source = await webClient.DownloadStringTaskAsync(url); + var detailsAudiblePageSource = new AudiblePageSource(AudiblePageType.ProductDetails, source, productId); + + // good habit to persist htm before attempting to parse it. this way, if there's a parse error, we can test errors on a local copy + DataConverter.AudiblePageSource_2_HtmFile_Product(detailsAudiblePageSource); + + bookDetailDTO = AudibleScraper.ScrapeBookDetailsSource(detailsAudiblePageSource); + } + catch (System.Net.WebException webEx) + { + // cannot continue if NoLongerAvailableAction is null, + // else we'll be right back here next loop (and infinitely) with no failure condition + if (webEx.Status != System.Net.WebExceptionStatus.ConnectionClosed || NoLongerAvailableAction == null) + throw; + + var nlaEnum = NoLongerAvailableAction.Invoke( + libraryBook.Book.Title, + AudiblePage.Product.GetUrl(libraryBook.Book.AudibleProductId)); + if (nlaEnum == NoLongerAvailableEnum.Abort) + return new StatusHandler { "Cannot scrape book details. Aborting." }; + else if (nlaEnum == NoLongerAvailableEnum.MarkAsMissing) + bookDetailDTO = new BookDetailDTO { ProductId = productId }; + else + throw; + } + } + } + + DataConverter.Value_2_JsonFile(bookDetailDTO, jsonFileInfo.FullName); + } + + await Indexer.IndexBookDetailsAsync(bookDetailDTO); + + BookSuccessfullyImported?.Invoke(this, productId); + + return new StatusHandler(); + } + } +} diff --git a/FileManager/FileManager.csproj b/FileManager/FileManager.csproj new file mode 100644 index 00000000..0ce9b091 --- /dev/null +++ b/FileManager/FileManager.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.1 + + + + + + + diff --git a/FileManager/UNTESTED/AudibleFileStorage.cs b/FileManager/UNTESTED/AudibleFileStorage.cs new file mode 100644 index 00000000..fac6ddeb --- /dev/null +++ b/FileManager/UNTESTED/AudibleFileStorage.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Dinah.Core; +using Dinah.Core.Collections.Generic; + +namespace FileManager +{ + // could add images here, but for now images are stored in a well-known location + public enum FileType { Unknown, Audio, AAX, PDF } + + /// + /// Files are large. File contents are never read by app. + /// Paths are varied. + /// Files are written during download/decrypt/backup/liberate. + /// Paths are read at app launch and during download/decrypt/backup/liberate. + /// Many files are often looked up at once + /// + public sealed class AudibleFileStorage : Enumeration + { + #region static + // centralize filetype mappings to ensure uniqueness + private static Dictionary extensionMap => new Dictionary + { + [".m4b"] = FileType.Audio, + [".mp3"] = FileType.Audio, + [".aac"] = FileType.Audio, + [".mp4"] = FileType.Audio, + [".m4a"] = FileType.Audio, + + [".aax"] = FileType.AAX, + + [".pdf"] = FileType.PDF, + [".zip"] = FileType.PDF, + }; + + public static AudibleFileStorage Audio { get; } + public static AudibleFileStorage AAX { get; } + public static AudibleFileStorage PDF { get; } + + public static string DownloadsInProgress { get; } + public static string DecryptInProgress { get; } + public static string BooksDirectory => Configuration.Instance.Books; + // not customizable. don't move to config + public static string DownloadsFinal { get; } + = new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("DownloadsFinal").FullName; + + static AudibleFileStorage() + { + #region init DecryptInProgress + if (!Configuration.Instance.DecryptInProgressEnum.In("WinTemp", "LibationFiles")) + Configuration.Instance.DecryptInProgressEnum = "WinTemp"; + var M4bRootDir + = Configuration.Instance.DecryptInProgressEnum == "WinTemp" // else "LibationFiles" + ? Configuration.Instance.WinTemp + : Configuration.Instance.LibationFiles; + DecryptInProgress = Path.Combine(M4bRootDir, "DecryptInProgress"); + Directory.CreateDirectory(DecryptInProgress); + #endregion + + #region init DownloadsInProgress + if (!Configuration.Instance.DownloadsInProgressEnum.In("WinTemp", "LibationFiles")) + Configuration.Instance.DownloadsInProgressEnum = "WinTemp"; + var AaxRootDir + = Configuration.Instance.DownloadsInProgressEnum == "WinTemp" // else "LibationFiles" + ? Configuration.Instance.WinTemp + : Configuration.Instance.LibationFiles; + DownloadsInProgress = Path.Combine(AaxRootDir, "DownloadsInProgress"); + Directory.CreateDirectory(DownloadsInProgress); + #endregion + + #region init BooksDirectory + if (string.IsNullOrWhiteSpace(Configuration.Instance.Books)) + Configuration.Instance.Books = Path.Combine(Configuration.Instance.LibationFiles, "Books"); + Directory.CreateDirectory(Configuration.Instance.Books); + #endregion + + // must do this in static ctor, not w/inline properties + // static properties init before static ctor so these dir.s would still be null + Audio = new AudibleFileStorage(FileType.Audio, BooksDirectory); + AAX = new AudibleFileStorage(FileType.AAX, DownloadsFinal); + PDF = new AudibleFileStorage(FileType.PDF, BooksDirectory); + } + #endregion + + #region instance + public FileType FileType => (FileType)Value; + + public string StorageDirectory => DisplayName; + + public IEnumerable Extensions => extensionMap.Where(kvp => kvp.Value == FileType).Select(kvp => kvp.Key); + + private AudibleFileStorage(FileType fileType, string storageDirectory) : base((int)fileType, storageDirectory) { } + + /// + /// Example for full books: + /// Search recursively in _books directory. Full book exists if either are true + /// - a directory name has the product id and an audio file is immediately inside + /// - any audio filename contains the product id + /// + public async Task ExistsAsync(string productId) + => (await GetAsync(productId).ConfigureAwait(false)) != null; + + public async Task GetAsync(string productId) + => await getAsync(productId).ConfigureAwait(false); + + private async Task getAsync(string productId) + { + { + var cachedFile = FilePathCache.GetPath(productId, FileType); + if (cachedFile != null) + return cachedFile; + } + + // this is how files are saved by default. check this method first + { + var diskFile_byDirName = (await Task.Run(() => getFile_checkDirName(productId)).ConfigureAwait(false)); + if (diskFile_byDirName != null) + { + FilePathCache.Upsert(productId, FileType, diskFile_byDirName); + return diskFile_byDirName; + } + } + + { + var diskFile_byFileName = (await Task.Run(() => getFile_checkFileName(productId, StorageDirectory, SearchOption.AllDirectories)).ConfigureAwait(false)); + if (diskFile_byFileName != null) + { + FilePathCache.Upsert(productId, FileType, diskFile_byFileName); + return diskFile_byFileName; + } + } + + return null; + } + + // returns audio file if there is a directory where both are true + // - the directory name contains the productId + // - the directory contains an audio file in it's top dir (not recursively) + private string getFile_checkDirName(string productId) + { + foreach (var d in Directory.EnumerateDirectories(StorageDirectory, "*.*", SearchOption.AllDirectories)) + { + if (!fileHasId(d, productId)) + continue; + + var firstAudio = Directory + .EnumerateFiles(d, "*.*", SearchOption.TopDirectoryOnly) + .FirstOrDefault(f => IsFileTypeMatch(f)); + if (firstAudio != null) + return firstAudio; + } + return null; + } + + // returns audio file if there is an file where both are true + // - the file name contains the productId + // - the file is an audio type + private string getFile_checkFileName(string productId, string dir, SearchOption searchOption) + => Directory + .EnumerateFiles(dir, "*.*", searchOption) + .FirstOrDefault(f => fileHasId(f, productId) && IsFileTypeMatch(f)); + + public bool IsFileTypeMatch(string filename) + => Extensions.ContainsInsensative(Path.GetExtension(filename)); + + public bool IsFileTypeMatch(FileInfo fileInfo) + => Extensions.ContainsInsensative(fileInfo.Extension); + + // use GetFileName, NOT GetFileNameWithoutExtension. This tests files AND directories. if the dir has a dot in the final part of the path, it will be treated like the file extension + private static bool fileHasId(string file, string productId) + => Path.GetFileName(file).ContainsInsensitive(productId); + #endregion + } +} diff --git a/FileManager/UNTESTED/Configuration.cs b/FileManager/UNTESTED/Configuration.cs new file mode 100644 index 00000000..47ceed16 --- /dev/null +++ b/FileManager/UNTESTED/Configuration.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using Dinah.Core; + +namespace FileManager +{ + public class Configuration + { + // settings will be persisted when all are true + // - property (not field) + // - string + // - public getter + // - public setter + + #region // properties to test reflection + /* + // field should NOT be populated + public string TestField; + // int should NOT be populated + public int TestInt { get; set; } + // read-only should NOT be populated + public string TestGet { get; } // get only: should NOT get auto-populated + // set-only should NOT be populated + public string TestSet { private get; set; } + + // get and set: SHOULD be auto-populated + public string TestGetSet { get; set; } + */ + #endregion + + private const string configFilename = "LibationSettings.json"; + + private PersistentDictionary persistentDictionary { get; } + + [Description("Location of the configuration file where these settings are saved. Please do not edit this file directly while Libation is running.")] + public string Filepath { get; } + + [Description("Your user-specific key used to decrypt your audible files (*.aax) into audio files you can use anywhere (*.m4b)")] + public string DecryptKey + { + get => persistentDictionary[nameof(DecryptKey)]; + set => persistentDictionary[nameof(DecryptKey)] = value; + } + + [Description("Location for book storage. Includes destination of newly liberated books")] + public string Books + { + get => persistentDictionary[nameof(Books)]; + set => persistentDictionary[nameof(Books)] = value; + } + + public string WinTemp { get; } = Path.Combine(Path.GetTempPath(), "Libation"); + + [Description("Location for storage of program-created files")] + public string LibationFiles + { + get => persistentDictionary[nameof(LibationFiles)]; + set => persistentDictionary[nameof(LibationFiles)] = value; + } + + // default setting and directory creation occur in class responsible for files. + // config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation + + // temp/working dir(s) should be outside of dropbox + [Description("Temporary location of files while they're in process of being downloaded.\r\nWhen download is complete, the final file will be in [LibationFiles]\\DownloadsFinal")] + public string DownloadsInProgressEnum + { + get => persistentDictionary[nameof(DownloadsInProgressEnum)]; + set => persistentDictionary[nameof(DownloadsInProgressEnum)] = value; + } + + // temp/working dir(s) should be outside of dropbox + [Description("Temporary location of files while they're in process of being decrypted.\r\nWhen decryption is complete, the final file will be in Books location")] + public string DecryptInProgressEnum + { + get => persistentDictionary[nameof(DecryptInProgressEnum)]; + set => persistentDictionary[nameof(DecryptInProgressEnum)] = value; + } + + // singleton stuff + public static Configuration Instance { get; } = new Configuration(); + private Configuration() + { + Filepath = getPath(); + + // load json values into memory + persistentDictionary = new PersistentDictionary(Filepath); + ensureDictionaryEntries(); + + // setUserFilesDirectoryDefault + // don't create dir. dir creation is the responsibility of places that use the dir + if (string.IsNullOrWhiteSpace(LibationFiles)) + LibationFiles = Path.Combine(Path.GetDirectoryName(Exe.FileLocationOnDisk), "Libation"); + } + + public static string GetDescription(string propertyName) + { + var attribute = typeof(Configuration) + .GetProperty(propertyName) + ?.GetCustomAttributes(typeof(DescriptionAttribute), true) + .SingleOrDefault() + as DescriptionAttribute; + + return attribute?.Description; + } + + private static string getPath() + { + // search folders for config file. accept the first match + var defaultdir = Path.GetDirectoryName(Exe.FileLocationOnDisk); + + var baseDirs = new HashSet + { + defaultdir, + getNonDevelopmentDir(defaultdir), + Environment.GetFolderPath(Environment.SpecialFolder.Personal) + }; + + var subDirs = baseDirs.Select(dir => Path.Combine(dir, "Libation")); + var dirs = baseDirs.Concat(subDirs).ToList(); + + foreach (var dir in dirs) + { + var f = Path.Combine(dir, configFilename); + if (File.Exists(f)) + return f; + } + + return Path.Combine(defaultdir, configFilename); + } + + private static string getNonDevelopmentDir(string path) + { + // examples: + // \Libation\Core2_0\bin\Debug\netcoreapp2.1 + // \Libation\StndLib\bin\Debug\netstandard2.0 + // \Libation\MyWnfrm\bin\Debug + // \Libation\Core2_0\bin\Release\netcoreapp2.1 + // \Libation\StndLib\bin\Release\netstandard2.0 + // \Libation\MyWnfrm\bin\Release + + var curr = new DirectoryInfo(path); + + if (!curr.Name.EqualsInsensitive("debug") && !curr.Name.EqualsInsensitive("release") && !curr.Name.StartsWithInsensitive("netcoreapp") && !curr.Name.StartsWithInsensitive("netstandard")) + return path; + + // get out of netcore/standard dir => debug + if (curr.Name.StartsWithInsensitive("netcoreapp") || curr.Name.StartsWithInsensitive("netstandard")) + curr = curr.Parent; + + if (!curr.Name.EqualsInsensitive("debug") && !curr.Name.EqualsInsensitive("release")) + return path; + + // get out of debug => bin + curr = curr.Parent; + if (!curr.Name.EqualsInsensitive("bin")) + return path; + + // get out of bin + curr = curr.Parent; + // get out of csproj-level dir + curr = curr.Parent; + + // curr should now be sln-level dir + return curr.FullName; + } + + private void ensureDictionaryEntries() + { + var stringProperties = getDictionaryProperties().Select(p => p.Name).ToList(); + var missingKeys = stringProperties.Except(persistentDictionary.Keys).ToArray(); + persistentDictionary.AddKeys(missingKeys); + } + + private IEnumerable dicPropertiesCache; + private IEnumerable getDictionaryProperties() + { + if (dicPropertiesCache == null) + dicPropertiesCache = PersistentDictionary.GetPropertiesToPersist(this.GetType()); + return dicPropertiesCache; + } + } +} diff --git a/FileManager/UNTESTED/FilePathCache.cs b/FileManager/UNTESTED/FilePathCache.cs new file mode 100644 index 00000000..f40d8bd8 --- /dev/null +++ b/FileManager/UNTESTED/FilePathCache.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; + +namespace FileManager +{ + public static class FilePathCache + { + internal class CacheEntry + { + public string Id { get; set; } + public FileType FileType { get; set; } + public string Path { get; set; } + } + + static List inMemoryCache = new List(); + + public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles, "FilePaths.json"); + + static FilePathCache() + { + // load json into memory. if file doesn't exist, nothing to do. save() will create if needed + if (FileUtility.FileExists(JsonFile)) + inMemoryCache = JsonConvert.DeserializeObject>(File.ReadAllText(JsonFile)); + } + + public static bool Exists(string id, FileType type) => GetPath(id, type) != null; + + public static string GetPath(string id, FileType type) + { + var entry = inMemoryCache.SingleOrDefault(i => i.Id == id && i.FileType == type); + + if (entry == null) + return null; + + if (!FileUtility.FileExists(entry.Path)) + { + remove(entry); + return null; + } + + return entry.Path; + } + + private static object locker { get; } = new object(); + + private static void remove(CacheEntry entry) + { + lock (locker) + { + inMemoryCache.Remove(entry); + save(); + } + } + + public static void Upsert(string id, FileType type, string path) + { + if (!FileUtility.FileExists(path)) + throw new FileNotFoundException("Cannot add path to cache. File not found"); + + lock (locker) + { + var entry = inMemoryCache.SingleOrDefault(i => i.Id == id && i.FileType == type); + if (entry != null) + entry.Path = path; + else + { + entry = new CacheEntry { Id = id, FileType = type, Path = path }; + inMemoryCache.Add(entry); + } + save(); + } + } + + // ONLY call this within lock() + private static void save() + { + // create json if not exists + void resave() => File.WriteAllText(JsonFile, JsonConvert.SerializeObject(inMemoryCache, Formatting.Indented)); + try { resave(); } + catch (IOException) + { + try { resave(); } + catch (IOException) + { + Console.WriteLine("...that's not good"); + throw; + } + } + } + } +} diff --git a/FileManager/UNTESTED/FileUtility.cs b/FileManager/UNTESTED/FileUtility.cs new file mode 100644 index 00000000..331edda0 --- /dev/null +++ b/FileManager/UNTESTED/FileUtility.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace FileManager +{ + public static class FileUtility + { + // a replacement for File.Exists() which allows long paths + // not needed in .net-core + public static bool FileExists(string path) + { + var basic = File.Exists(path); + if (basic) + return true; + + // character cutoff is usually 269 but this isn't a hard number. there are edgecases which shorted the threshold + if (path.Length < 260) + return false; + + // try long name prefix: + // \\?\ + // https://blogs.msdn.microsoft.com/jeremykuhne/2016/06/21/more-on-new-net-path-handling/ + path = @"\\?\" + path; + + return File.Exists(path); + } + + /// acceptable inputs: + /// example.txt + /// C:\Users\username\Desktop\example.txt + /// Returns full name and path of unused filename. including (#) + public static string GetValidFilename(string proposedPath) + => GetValidFilename(Path.GetDirectoryName(proposedPath), Path.GetFileNameWithoutExtension(proposedPath), Path.GetExtension(proposedPath)); + + public static string GetValidFilename(string dirFullPath, string filename, string extension, params string[] metadataSuffixes) + { + if (string.IsNullOrWhiteSpace(dirFullPath)) + throw new ArgumentException($"{nameof(dirFullPath)} may not be null or whitespace", nameof(dirFullPath)); + + // file max length = 255. dir max len = 247 + + // sanitize + filename = GetAsciiTag(filename); + // manage length + if (filename.Length > 50) + filename = filename.Substring(0, 50) + "[...]"; + + // append id. it is 10 or 14 char in the common cases + if (metadataSuffixes != null && metadataSuffixes.Length > 0) + filename += " [" + string.Join("][", metadataSuffixes) + "]"; + + // this method may also be used for directory names, so no guarantee of extension + if (!string.IsNullOrWhiteSpace(extension)) + extension = '.' + extension.Trim('.'); + + // ensure uniqueness + var fullfilename = Path.Combine(dirFullPath, filename + extension); + var i = 0; + while (FileExists(fullfilename)) + fullfilename = Path.Combine(dirFullPath, filename + $" ({++i})" + extension); + + return fullfilename; + } + + public static string GetAsciiTag(string property) + { + if (property == null) + return ""; + + // omit characters which are invalid. EXCEPTION: change colon to underscore + property = property.Replace(':', '_'); + + // GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/' + foreach (var ch in Path.GetInvalidFileNameChars()) + property = property.Replace(ch.ToString(), ""); + return property; + } + + public static string Declaw(string str) + => str + .Replace(" str + ?.Replace(" new string(title + .Where(c => (char.IsLetterOrDigit(c))) + .ToArray()); + } +} diff --git a/FileManager/UNTESTED/PersistentDictionary.cs b/FileManager/UNTESTED/PersistentDictionary.cs new file mode 100644 index 00000000..27ebfdcd --- /dev/null +++ b/FileManager/UNTESTED/PersistentDictionary.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; + +namespace FileManager +{ + public class PersistentDictionary + { + public string Filepath { get; } + + // forgiving -- doesn't drop settings. old entries will continue to be persisted even if not publicly visible + private Dictionary settingsDic { get; } + + public string this[string key] + { + get => settingsDic[key]; + set + { + if (settingsDic.ContainsKey(key) && settingsDic[key] == value) + return; + + settingsDic[key] = value; + + // auto-save to file + save(); + } + } + + public PersistentDictionary(string filepath) + { + Filepath = filepath; + + // not found. create blank file + if (!File.Exists(Filepath)) + { + File.WriteAllText(Filepath, "{}"); + + // give system time to create file before first use + System.Threading.Thread.Sleep(100); + } + + settingsDic = JsonConvert.DeserializeObject>(File.ReadAllText(Filepath)); + } + + public IEnumerable Keys => settingsDic.Keys.Cast(); + + public void AddKeys(params string[] keys) + { + if (keys == null || keys.Length == 0) + return; + + foreach (var key in keys) + settingsDic.Add(key, null); + save(); + } + + private object locker { get; } = new object(); + private void save() + { + lock (locker) + File.WriteAllText(Filepath, JsonConvert.SerializeObject(settingsDic, Formatting.Indented)); + } + + public static IEnumerable GetPropertiesToPersist(Type type) + => type + .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(p => + // string properties only + p.PropertyType == typeof(string) + // exclude indexer + && p.GetIndexParameters().Length == 0 + // exclude read-only, write-only + && p.GetGetMethod(false) != null + && p.GetSetMethod(false) != null + ).ToList(); + } +} diff --git a/FileManager/UNTESTED/PictureStorage.cs b/FileManager/UNTESTED/PictureStorage.cs new file mode 100644 index 00000000..d4572f69 --- /dev/null +++ b/FileManager/UNTESTED/PictureStorage.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace FileManager +{ + /// + /// Files are small. Entire file is read from disk every time. No volitile storage. Paths are well known + /// + public static class PictureStorage + { + public enum PictureSize { _80x80, _300x300, _500x500 } + + // not customizable. don't move to config + private static string ImagesDirectory { get; } + = new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("Images").FullName; + + private static string getPath(string pictureId, PictureSize size) + => Path.Combine(ImagesDirectory, $"{pictureId}{size}.jpg"); + + public static byte[] GetImage(string pictureId, PictureSize size) + { + var path = getPath(pictureId, size); + if (!FileUtility.FileExists(path)) + DownloadImages(pictureId); + + return File.ReadAllBytes(path); + } + + public static void DownloadImages(string pictureId) + { + var path80 = getPath(pictureId, PictureSize._80x80); + var path300 = getPath(pictureId, PictureSize._300x300); + var path500 = getPath(pictureId, PictureSize._500x500); + + int retry = 0; + do + { + try + { + using (var webClient = new System.Net.WebClient()) + { + // download any that don't exist + { + if (!FileUtility.FileExists(path80)) + { + var bytes = webClient.DownloadData( + "https://images-na.ssl-images-amazon.com/images/I/" + pictureId + "._SL80_.jpg"); + File.WriteAllBytes(path80, bytes); + } + } + + { + if (!FileUtility.FileExists(path300)) + { + var bytes = webClient.DownloadData( + "https://images-na.ssl-images-amazon.com/images/I/" + pictureId + "._SL300_.jpg"); + File.WriteAllBytes(path300, bytes); + } + } + + { + if (!FileUtility.FileExists(path500)) + { + var bytes = webClient.DownloadData( + "https://m.media-amazon.com/images/I/" + pictureId + "._SL500_.jpg"); + File.WriteAllBytes(path500, bytes); + } + } + + break; + } + } + catch { retry++; } + } + while (retry < 3); + } + } +} diff --git a/FileManager/UNTESTED/QuickFilters.cs b/FileManager/UNTESTED/QuickFilters.cs new file mode 100644 index 00000000..017c22bd --- /dev/null +++ b/FileManager/UNTESTED/QuickFilters.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Dinah.Core.Collections.Generic; +using Newtonsoft.Json; + +namespace FileManager +{ + public static class QuickFilters + { + internal class FilterState + { + public bool UseDefault { get; set; } + public List Filters { get; set; } = new List(); + } + + static FilterState inMemoryState { get; } = new FilterState(); + + public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles, "QuickFilters.json"); + + static QuickFilters() + { + // load json into memory. if file doesn't exist, nothing to do. save() will create if needed + if (FileUtility.FileExists(JsonFile)) + inMemoryState = JsonConvert.DeserializeObject(File.ReadAllText(JsonFile)); + } + + public static bool UseDefault + { + get => inMemoryState.UseDefault; + set + { + lock (locker) + { + inMemoryState.UseDefault = value; + save(); + } + } + } + + public static IEnumerable Filters => inMemoryState.Filters.AsReadOnly(); + + public static void Add(string filter) + { + if (string.IsNullOrWhiteSpace(filter)) + return; + filter = filter.Trim(); + + lock (locker) + { + // check for duplicate + if (inMemoryState.Filters.ContainsInsensative(filter)) + return; + + inMemoryState.Filters.Add(filter); + save(); + } + } + + public static void Remove(string filter) + { + lock (locker) + { + inMemoryState.Filters.Remove(filter); + save(); + } + } + + public static void Edit(string oldFilter, string newFilter) + { + lock (locker) + { + var index = inMemoryState.Filters.IndexOf(oldFilter); + if (index < 0) + return; + + inMemoryState.Filters = inMemoryState.Filters.Select(f => f == oldFilter ? newFilter : f).ToList(); + + save(); + } + } + + public static void ReplaceAll(IEnumerable filters) + { + filters = filters + .Where(f => !string.IsNullOrWhiteSpace(f)) + .Distinct() + .Select(f => f.Trim()); + lock (locker) + { + inMemoryState.Filters = new List(filters); + save(); + } + } + + private static object locker { get; } = new object(); + + // ONLY call this within lock() + private static void save() + { + // create json if not exists + void resave() => File.WriteAllText(JsonFile, JsonConvert.SerializeObject(inMemoryState, Formatting.Indented)); + try { resave(); } + catch (IOException) + { + try { resave(); } + catch (IOException) + { + Console.WriteLine("...that's not good"); + throw; + } + } + } + } +} diff --git a/FileManager/UNTESTED/TagsPersistence.cs b/FileManager/UNTESTED/TagsPersistence.cs new file mode 100644 index 00000000..90f5349a --- /dev/null +++ b/FileManager/UNTESTED/TagsPersistence.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; + +namespace FileManager +{ + /// + /// Tags must also be stored in db for search performance. Stored in json file to survive a db reset. + /// json is only read when a product is first loaded + /// json is only written to when tags are edited + /// json access is infrequent and one-off + /// all other reads happen against db. No volitile storage + /// + public static class TagsPersistence + { + public static string TagsFile => Path.Combine(Configuration.Instance.LibationFiles, "BookTags.json"); + + private static object locker { get; } = new object(); + + public static void Save(string productId, string tags) + => System.Threading.Tasks.Task.Run(() => save_fireAndForget(productId, tags)); + + private static void save_fireAndForget(string productId, string tags) + { + lock (locker) + { + // get all + var allDictionary = retrieve(); + + // update/upsert tag list + allDictionary[productId] = tags; + + // re-save: + // this often fails the first time with + // The requested operation cannot be performed on a file with a user-mapped section open. + // 2nd immediate attempt failing was rare. So I added sleep. We'll see... + void resave() => File.WriteAllText(TagsFile, JsonConvert.SerializeObject(allDictionary, Formatting.Indented)); + try { resave(); } + catch (IOException debugEx) + { + // 1000 was always reliable but very slow. trying other values + var waitMs = 100; + + System.Threading.Thread.Sleep(waitMs); + resave(); + } + } + } + + public static string GetTags(string productId) + { + var dic = retrieve(); + return dic.ContainsKey(productId) ? dic[productId] : null; + } + + private static Dictionary retrieve() + { + if (!FileUtility.FileExists(TagsFile)) + return new Dictionary(); + lock (locker) + return JsonConvert.DeserializeObject>(File.ReadAllText(TagsFile)); + } + } +} diff --git a/FileManager/UNTESTED/WebpageStorage.cs b/FileManager/UNTESTED/WebpageStorage.cs new file mode 100644 index 00000000..ab4c5e6e --- /dev/null +++ b/FileManager/UNTESTED/WebpageStorage.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace FileManager +{ + public static class WebpageStorage + { + // not customizable. don't move to config + private static string PagesDirectory { get; } + = new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("Pages").FullName; + private static string BookDetailsDirectory { get; } + = new DirectoryInfo(PagesDirectory).CreateSubdirectory("Book Details").FullName; + + public static string GetLibraryBatchName() => "Library_" + DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + public static string SavePageToBatch(string contents, string batchName, string extension) + { + var batch_dir = Path.Combine(PagesDirectory, batchName); + + Directory.CreateDirectory(batch_dir); + + var file = Path.Combine(batch_dir, batchName + '.' + extension.Trim('.')); + var filename = FileUtility.GetValidFilename(file); + File.WriteAllText(filename, contents); + + return filename; + } + + public static List GetJsonFiles(DirectoryInfo libDir) + => libDir == null + ? new List() + : Directory + .EnumerateFiles(libDir.FullName, "*.json") + .Select(f => new FileInfo(f)) + .ToList(); + + public static DirectoryInfo GetMostRecentLibraryDir() + { + var dir = Directory + .EnumerateDirectories(PagesDirectory, "Library_*") + .OrderBy(a => a) + .LastOrDefault(); + if (string.IsNullOrWhiteSpace(dir)) + return null; + return new DirectoryInfo(dir); + } + + public static FileInfo GetBookDetailHtmFileInfo(string productId) + { + var path = Path.Combine(BookDetailsDirectory, $"BookDetail-{productId}.htm"); + return new FileInfo(path); + } + + public static FileInfo GetBookDetailJsonFileInfo(string productId) + { + var path = Path.Combine(BookDetailsDirectory, $"BookDetail-{productId}.json"); + return new FileInfo(path); + } + + public static FileInfo SaveBookDetailsToHtm(string productId, string contents) + { + var fi = GetBookDetailHtmFileInfo(productId); + File.WriteAllText(fi.FullName, contents); + return fi; + } + } +} diff --git a/InternalUtilities/InternalUtilities.csproj b/InternalUtilities/InternalUtilities.csproj new file mode 100644 index 00000000..1743dfd0 --- /dev/null +++ b/InternalUtilities/InternalUtilities.csproj @@ -0,0 +1,13 @@ + + + + netstandard2.1 + + + + + + + + + diff --git a/InternalUtilities/UNTESTED/DataConverter.cs b/InternalUtilities/UNTESTED/DataConverter.cs new file mode 100644 index 00000000..c6f8f10a --- /dev/null +++ b/InternalUtilities/UNTESTED/DataConverter.cs @@ -0,0 +1,51 @@ +using System.IO; +using AudibleDotCom; +using FileManager; +using Newtonsoft.Json; + +namespace InternalUtilities +{ + public static partial class DataConverter + { + // also need: htm file => PageSource + + public static AudiblePageSource HtmFile_2_AudiblePageSource(string htmFilepath) + { + var htmContentsDeclawed = File.ReadAllText(htmFilepath); + var htmContents = FileUtility.RestoreDeclawed(htmContentsDeclawed); + return AudiblePageSource.Deserialize(htmContents); + } + + public static FileInfo Value_2_JsonFile(object value, string jsonFilepath) + { + var json = JsonConvert.SerializeObject(value, Formatting.Indented); + + File.WriteAllText(jsonFilepath, json); + + return new FileInfo(jsonFilepath); + } + + /// AudiblePageSource => declawed htm file + /// path of htm file + public static FileInfo AudiblePageSource_2_HtmFile_Batch(AudiblePageSource audiblePageSource, string batchName) + { + var source = audiblePageSource.Declawed().Serialized(); + var htmFile = WebpageStorage.SavePageToBatch(source, batchName, "htm"); + return new FileInfo(htmFile); + } + + /// AudiblePageSource => declawed htm file + /// path of htm file + public static FileInfo AudiblePageSource_2_HtmFile_Product(AudiblePageSource audiblePageSource) + { + if (audiblePageSource.AudiblePage == AudiblePageType.ProductDetails) + { + var source = audiblePageSource.Declawed().Serialized(); + var htmFile = WebpageStorage.SaveBookDetailsToHtm(audiblePageSource.PageId, source); + return htmFile; + } + + throw new System.NotImplementedException(); + } + } +} diff --git a/InternalUtilities/UNTESTED/DtoImporter.cs b/InternalUtilities/UNTESTED/DtoImporter.cs new file mode 100644 index 00000000..9ae6d2b4 --- /dev/null +++ b/InternalUtilities/UNTESTED/DtoImporter.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DataLayer; +using Scraping.BookDetail; +using Scraping.Library; + +namespace InternalUtilities +{ + public class DtoImporter + { + LibationContext context { get; } + + public DtoImporter(LibationContext context) => this.context = context; + + #region LibraryDTO + /// LONG RUNNING. call with await Task.Run + public int ReplaceLibrary(List requestedLibraryDTOs) + { + upsertContributors(requestedLibraryDTOs); + upsertSeries(requestedLibraryDTOs); + upsertBooks(requestedLibraryDTOs); + + var newAddedCount = upsertLibraryBooks(requestedLibraryDTOs); + + //ReloadBookDetails(requestedLibraryDTOs); + #region // explanation of: cannot ReloadBookDetails() until context.SaveChanges() + /* + setup: + library page shows narrators "bob smith" "kevin jones" "and others" + book details shows narrators "bob smith" "kevin jones" "alice liddell" + + error + creates BookContributors with same keys, even though one is orphaned + "The instance of entity type cannot be tracked because another instance with the same key value for {'Id'} is already being tracked" + https://github.com/aspnet/EntityFrameworkCore/issues/12459 + + solution: + replace library + creates library version + save + update book details + adds new book details version + removes library version + */ + #endregion + + return newAddedCount; + } + + private void upsertContributors(List requestedLibraryDTOs) + { + var authorTuples = requestedLibraryDTOs.SelectMany(dto => dto.Authors).ToList(); + var narratorNames = requestedLibraryDTOs.SelectMany(dto => dto.Narrators).ToList(); + + var allNames = authorTuples + .Select(a => a.authorName) + .Union(narratorNames) + .ToList(); + loadLocal_contributors(allNames); + + upsertAuthors(authorTuples); + upsertNarrators(narratorNames); + } + + private void upsertSeries(List requestedLibraryDTOs) + { + var requestedSeries = requestedLibraryDTOs + .SelectMany(l => l.Series) + .Select(kvp => (seriesId: kvp.Key, seriesName: kvp.Value)) + .ToList(); + + upsertSeries(requestedSeries); + } + + private void upsertBooks(List requestedLibraryDTOs) + { + loadLocal_books(requestedLibraryDTOs.Select(dto => dto.ProductId).ToList()); + + foreach (var libraryDTO in requestedLibraryDTOs) + upsertBook(libraryDTO); + } + + private void upsertBook(LibraryDTO libraryDTO) + { + var book = context.Books.Local.SingleOrDefault(p => p.AudibleProductId == libraryDTO.ProductId); + if (book == null) + { + // nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db + var authors = libraryDTO + .Authors + .Select(t => context.Contributors.Local.Single(c => t.authorName == c.Name)) + .ToList(); + + // if no narrators listed, author is the narrator + if (!libraryDTO.Narrators.Any()) + libraryDTO.Narrators = libraryDTO.Narrators = authors.Select(a => a.Name).ToArray(); + + // nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db + var narrators = libraryDTO + .Narrators + .Select(n => context.Contributors.Local.Single(c => n == c.Name)) + .ToList(); + + book = context.Books.Add(new Book( + new AudibleProductId(libraryDTO.ProductId), libraryDTO.Title, libraryDTO.Description, libraryDTO.LengthInMinutes, authors, narrators)) + .Entity; + } + + // set/update book-specific info which may have changed + book.PictureId = libraryDTO.PictureId; + book.UpdateProductRating(libraryDTO.Product_OverallStars, libraryDTO.Product_PerformanceStars, libraryDTO.Product_StoryStars); + foreach (var url in libraryDTO.SupplementUrls) + book.AddSupplementDownloadUrl(url); + + // important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import + book.UserDefinedItem.UpdateRating(libraryDTO.MyUserRating_Overall, libraryDTO.MyUserRating_Performance, libraryDTO.MyUserRating_Story); + + // update series even for existing books. these are occasionally updated + var seriesIds = libraryDTO.Series.Select(kvp => kvp.Key).ToList(); + var allSeries = context.Series.Local.Where(c => seriesIds.Contains(c.AudibleSeriesId)).ToList(); + foreach (var series in allSeries) + book.UpsertSeries(series); + } + + private int upsertLibraryBooks(List requestedLibraryDTOs) + { + var currentLibraryProductIds = context.Library.Select(l => l.Book.AudibleProductId).ToList(); + List newLibraryDTOs = requestedLibraryDTOs.Where(dto => !currentLibraryProductIds.Contains(dto.ProductId)).ToList(); + + foreach (var newLibraryDTO in newLibraryDTOs) + { + var libraryBook = new LibraryBook( + context.Books.Local.Single(b => b.AudibleProductId == newLibraryDTO.ProductId), + newLibraryDTO.DateAdded, + FileManager.FileUtility.RestoreDeclawed(newLibraryDTO.DownloadBookLink)); + context.Library.Add(libraryBook); + } + + return newLibraryDTOs.Count; + } + + /// LONG RUNNING. call with await Task.Run + public void ReloadBookDetails(List requestedLibraryDTOs) + { + var dtos = requestedLibraryDTOs + .Select(dto => dto.ProductId) + .Distinct() + .Select(productId => FileManager.WebpageStorage.GetBookDetailJsonFileInfo(productId)) + .Where(fi => fi.Exists) + .Select(fi => Newtonsoft.Json.JsonConvert.DeserializeObject(System.IO.File.ReadAllText(fi.FullName))) + .ToList(); + if (dtos.Any()) + UpdateBookDetails(dtos); + } + #endregion + + #region BookDetailDTO + /// LONG RUNNING. call with await Task.Run + public void UpdateBookDetails(BookDetailDTO bookDetailDTO) => UpdateBookDetails(new List { bookDetailDTO }); + /// LONG RUNNING. call with await Task.Run + public void UpdateBookDetails(List bookDetailDTOs) + { + upsertContributors(bookDetailDTOs); + upsertCategories(bookDetailDTOs); + upsertSeries(bookDetailDTOs); + updateBooks(bookDetailDTOs); + } + + private void upsertContributors(List bookDetailDTOs) + { + var narratorNames = bookDetailDTOs.SelectMany(dto => dto.Narrators).ToList(); + var publisherNames = bookDetailDTOs.Select(dto => dto.Publisher).Where(p => !string.IsNullOrWhiteSpace(p)).ToList(); + + var allNames = narratorNames.Union(publisherNames).ToList(); + loadLocal_contributors(allNames); + + upsertNarrators(narratorNames); + upsertPublishers(publisherNames); + } + + private void upsertCategories(List bookDetailDTOs) + { + var categoryIds = bookDetailDTOs.SelectMany(dto => dto.Categories).Select(c => c.categoryId).ToList(); + loadLocal_categories(categoryIds); + + foreach (var dto in bookDetailDTOs) + upsertCategories(dto); + } + + private void upsertCategories(BookDetailDTO bookDetailDTO) + { + if (bookDetailDTO.Categories.Count == 0) + return; + + if (bookDetailDTO.Categories.Count < 1 || bookDetailDTO.Categories.Count > 2) + throw new Exception("expecting either 1 or 2 categories"); + + for (var i = 0; i < bookDetailDTO.Categories.Count; i++) + { + var (categoryId, categoryName) = bookDetailDTO.Categories[i]; + + Category parentCategory = null; + if (i == 1) + parentCategory = context.Categories.Local.Single(c => c.AudibleCategoryId == bookDetailDTO.Categories[0].categoryId); + + var category + = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == categoryId) + ?? context.Categories.Add(new Category(new AudibleCategoryId(categoryId), categoryName)).Entity; + category.UpdateParentCategory(parentCategory); + } + } + + private void upsertSeries(List bookDetailDTOs) + { + var requestedSeries = bookDetailDTOs + .SelectMany(l => l.Series) + .Select(s => (seriesId: s.SeriesId, seriesName: s.SeriesName)) + .ToList(); + + upsertSeries(requestedSeries); + } + + private void updateBooks(List bookDetailDTOs) + { + loadLocal_books(bookDetailDTOs.Select(dto => dto.ProductId).ToList()); + + foreach (var dto in bookDetailDTOs) + updateBook(dto); + } + + private void updateBook(BookDetailDTO bookDetailDTO) + { + var book = context.Books.Local.Single(b => b.AudibleProductId == bookDetailDTO.ProductId); + + // nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db + var narrators = bookDetailDTO + .Narrators + .Select(n => context.Contributors.Local.Single(c => n == c.Name)) + .ToList(); + // not all books have narrators. these will already be using author as narrator. don't undo this + if (narrators.Any()) + book.ReplaceNarrators(narrators); + + var publisherName = bookDetailDTO.Publisher; + if (!string.IsNullOrWhiteSpace(publisherName)) + { + var publisher = context.Contributors.Local.Single(c => publisherName == c.Name); + book.ReplacePublisher(publisher); + } + + // these will upsert over library-scraped series, but will not leave orphans + foreach (var seriesEntry in bookDetailDTO.Series) + { + var series = context.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId); + book.UpsertSeries(series, seriesEntry.Index); + } + + // categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd + var category = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == bookDetailDTO.Categories.LastOrDefault().categoryId); + if (category != null) + book.UpdateCategory(category, context); + + book.UpdateBookDetails(bookDetailDTO.IsAbridged, bookDetailDTO.DatePublished); + } + #endregion + + #region load db existing => .Local + private void loadLocal_contributors(List contributorNames) + { + //// BAD: very inefficient + // var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name)); + + // GOOD: Except() is efficient. Due to hashing, it's close to O(n) + var localNames = context.Contributors.Local.Select(c => c.Name); + var remainingContribNames = contributorNames + .Distinct() + .Except(localNames) + .ToList(); + + // load existing => local + if (remainingContribNames.Any()) + context.Contributors.Where(c => remainingContribNames.Contains(c.Name)).ToList(); + // _________________________________^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + // i tried to extract this pattern, but this part prohibits doing so + // wouldn't work anyway for Books.GetBooks() + } + + private void loadLocal_series(List seriesIds) + { + var localIds = context.Series.Local.Select(s => s.AudibleSeriesId); + var remainingSeriesIds = seriesIds + .Distinct() + .Except(localIds) + .ToList(); + + if (remainingSeriesIds.Any()) + context.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList(); + } + + private void loadLocal_books(List productIds) + { + var localProductIds = context.Books.Local.Select(b => b.AudibleProductId); + var remainingProductIds = productIds + .Distinct() + .Except(localProductIds) + .ToList(); + + // GetBooks() eager loads Series, category, et al + if (remainingProductIds.Any()) + context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList(); + } + + private void loadLocal_categories(List categoryIds) + { + var localIds = context.Categories.Local.Select(c => c.AudibleCategoryId); + var remainingCategoryIds = categoryIds + .Distinct() + .Except(localIds) + .ToList(); + + if (remainingCategoryIds.Any()) + context.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId)).ToList(); + } + #endregion + + // only use after loading contributors => local + private void upsertAuthors(List<(string authorName, string authorId)> requestedAuthors) + { + var distinctAuthors = requestedAuthors.Distinct().ToList(); + + foreach (var (authorName, authorId) in distinctAuthors) + { + var author + = context.Contributors.Local.SingleOrDefault(c => c.Name == authorName) + ?? context.Contributors.Add(new Contributor(authorName)).Entity; + author.UpdateAudibleAuthorId(authorId); + } + } + + // only use after loading contributors => local + private void upsertNarrators(List requestedNarratorNames) + { + var distinctNarrators = requestedNarratorNames.Distinct().ToList(); + + foreach (var reqNarratorName in distinctNarrators) + if (context.Contributors.Local.SingleOrDefault(c => c.Name == reqNarratorName) == null) + context.Contributors.Add(new Contributor(reqNarratorName)); + } + + // only use after loading contributors => local + private void upsertPublishers(List requestedPublisherNames) + { + var distinctPublishers = requestedPublisherNames.Distinct().ToList(); + + foreach (var reqPublisherName in distinctPublishers) + if (context.Contributors.Local.SingleOrDefault(c => c.Name == reqPublisherName) == null) + context.Contributors.Add(new Contributor(reqPublisherName)); + } + + private void upsertSeries(List<(string seriesId, string seriesName)> requestedSeries) + { + var distinctSeries = requestedSeries.Distinct().ToList(); + var requestedSeriesIds = distinctSeries + .Select(r => r.seriesId) + .Distinct() + .ToList(); + + loadLocal_series(requestedSeriesIds); + + foreach (var (seriesId, seriesName) in distinctSeries) + { + var series + = context.Series.Local.SingleOrDefault(c => c.AudibleSeriesId == seriesId) + ?? context.Series.Add(new Series(new AudibleSeriesId(seriesId))).Entity; + series.UpdateName(seriesName); + } + } + } +} diff --git a/InternalUtilities/UNTESTED/SearchEngineActions.cs b/InternalUtilities/UNTESTED/SearchEngineActions.cs new file mode 100644 index 00000000..97197c53 --- /dev/null +++ b/InternalUtilities/UNTESTED/SearchEngineActions.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using DataLayer; + +namespace InternalUtilities +{ + public static class SearchEngineActions + { + public static async Task FullReIndexAsync() + { + var engine = new LibationSearchEngine.SearchEngine(); + await engine.CreateNewIndexAsync().ConfigureAwait(false); + } + + public static void UpdateBookTags(Book book) + { + var engine = new LibationSearchEngine.SearchEngine(); + engine.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags); + } + + public static async Task ProductReIndexAsync(string productId) + { + var engine = new LibationSearchEngine.SearchEngine(); + await engine.UpdateBookAsync(productId).ConfigureAwait(false); + } + } +} diff --git a/Libation.sln b/Libation.sln new file mode 100644 index 00000000..76f8142c --- /dev/null +++ b/Libation.sln @@ -0,0 +1,234 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.28803.156 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}" + ProjectSection(SolutionItems) = preProject + __TODO.txt = __TODO.txt + _DB_NOTES.txt = _DB_NOTES.txt + lucenenet source code.txt = lucenenet source code.txt + REFERENCE.txt = REFERENCE.txt + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3.2 Domain Utilities (post database)", "3.2 Domain Utilities (post database)", "{41CDCC73-9B81-49DD-9570-C54406E852AF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "4 Application", "4 Application", "{8679CAC8-9164-4007-BDD2-F004810EDA14}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 Core Libraries", "1 Core Libraries", "{43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 Domain", "3 Domain", "{751093DD-5DBA-463E-ADBE-E05FAFB6983E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 Utilities (domain ignorant)", "2 Utilities (domain ignorant)", "{7FBBB086-0807-4998-85BF-6D1A49C8AD05}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AaxDecrypter", "AaxDecrypter\AaxDecrypter.csproj", "{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager", "FileManager\FileManager.csproj", "{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataLayer", "DataLayer\DataLayer.csproj", "{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleDotCom", "AudibleDotCom\AudibleDotCom.csproj", "{4ABB61D3-4959-4F09-883A-9EDC8CE473FB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Scraping", "Scraping\Scraping.csproj", "{C2C89551-44FD-41E4-80D3-69AF8CE3F174}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleDotComAutomation", "AudibleDotComAutomation\AudibleDotComAutomation.csproj", "{4CDE10DD-60EC-4CCA-99D1-75224A201C89}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CookieMonster", "CookieMonster\CookieMonster.csproj", "{7BD02E29-3430-4D06-88D2-5CECEE9ABD01}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DomainServices", "DomainServices\DomainServices.csproj", "{393B5B27-D15C-4F77-9457-FA14BA8F3C73}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InternalUtilities", "InternalUtilities\InternalUtilities.csproj", "{06882742-27A6-4347-97D9-56162CEC9C11}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3.1 Domain Internal Utilities", "3.1 Domain Internal Utilities", "{F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine", "LibationSearchEngine\LibationSearchEngine.csproj", "{2E1F5DB4-40CC-4804-A893-5DCE0193E598}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Tests", "0 Tests", "{38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Demos and Examples", "0 Demos and Examples", "{F61184E7-2426-4A13-ACEF-5689928E2CE2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.Demos", "..\Dinah.Core\_Demos\Dinah.Core.Demos\Dinah.Core.Demos.csproj", "{9F1AA3DE-962F-469B-82B2-46F93491389B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.Tests", "..\Dinah.Core\_Tests\Dinah.Core.Tests\Dinah.Core.Tests.csproj", "{E874D000-AD3A-4629-AC65-7219C2C7C1F0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestCommon", "..\Dinah.Core\_Tests\TestCommon\TestCommon.csproj", "{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitAllRepos", "..\GitAllRepos\GitAllRepos\GitAllRepos.csproj", "{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi", "..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj", "{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApiClientExample", "..\audible api\AudibleApi\AudibleApiClientExample\AudibleApiClientExample.csproj", "{959D01B4-5EF8-4D4E-BE06-AB1A580B0B52}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi.Tests", "..\audible api\AudibleApi\_Tests\AudibleApi.Tests\AudibleApi.Tests.csproj", "{111420E2-D4F0-4068-B46A-C4B6DCC823DC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationWinForm", "LibationWinForm\LibationWinForm.csproj", "{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinFormsDesigner", "WinFormsDesigner\WinFormsDesigner.csproj", "{0807616A-A77A-4B08-A65A-1582B09E114B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ffmpeg decrypt", "_Demos\ffmpeg decrypt\ffmpeg decrypt.csproj", "{DF72740C-900A-45DA-A3A6-4DDD68F286F2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "inAudibleLite", "_Demos\inAudibleLite\inAudibleLite.csproj", "{74D02251-898E-4CAF-80C7-801820622903}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core", "..\Dinah.Core\Dinah.Core\Dinah.Core.csproj", "{9E951521-2587-4FC6-AD26-FAA9179FB6C4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.Drawing", "..\Dinah.Core\Dinah.Core.Drawing\Dinah.Core.Drawing.csproj", "{2CAAD73E-E2F9-4888-B04A-3F3803DABDAE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.Windows.Forms", "..\Dinah.Core\Dinah.Core.Windows.Forms\Dinah.Core.Windows.Forms.csproj", "{1306F62D-CDAC-4269-982A-2EED51F0E318}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.EntityFrameworkCore", "..\Dinah.Core\Dinah.EntityFrameworkCore\Dinah.EntityFrameworkCore.csproj", "{1255D9BA-CE6E-42E4-A253-6376540B9661}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LuceneNet303r2", "..\LuceneNet303r2\LuceneNet303r2\LuceneNet303r2.csproj", "{35803735-B669-4090-9681-CC7F7FABDC71}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LuceneNet303r2.Tests", "..\LuceneNet303r2\LuceneNet303r2.Tests\LuceneNet303r2.Tests.csproj", "{5A7681A5-60D9-480B-9AC7-63E0812A2548}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}.Release|Any CPU.Build.0 = Release|Any CPU + {1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}.Release|Any CPU.Build.0 = Release|Any CPU + {59A10DF3-63EC-43F1-A3BF-4000CFA118D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59A10DF3-63EC-43F1-A3BF-4000CFA118D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59A10DF3-63EC-43F1-A3BF-4000CFA118D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59A10DF3-63EC-43F1-A3BF-4000CFA118D2}.Release|Any CPU.Build.0 = Release|Any CPU + {4ABB61D3-4959-4F09-883A-9EDC8CE473FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4ABB61D3-4959-4F09-883A-9EDC8CE473FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4ABB61D3-4959-4F09-883A-9EDC8CE473FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4ABB61D3-4959-4F09-883A-9EDC8CE473FB}.Release|Any CPU.Build.0 = Release|Any CPU + {C2C89551-44FD-41E4-80D3-69AF8CE3F174}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2C89551-44FD-41E4-80D3-69AF8CE3F174}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2C89551-44FD-41E4-80D3-69AF8CE3F174}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2C89551-44FD-41E4-80D3-69AF8CE3F174}.Release|Any CPU.Build.0 = Release|Any CPU + {4CDE10DD-60EC-4CCA-99D1-75224A201C89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CDE10DD-60EC-4CCA-99D1-75224A201C89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CDE10DD-60EC-4CCA-99D1-75224A201C89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CDE10DD-60EC-4CCA-99D1-75224A201C89}.Release|Any CPU.Build.0 = Release|Any CPU + {7BD02E29-3430-4D06-88D2-5CECEE9ABD01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BD02E29-3430-4D06-88D2-5CECEE9ABD01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BD02E29-3430-4D06-88D2-5CECEE9ABD01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BD02E29-3430-4D06-88D2-5CECEE9ABD01}.Release|Any CPU.Build.0 = Release|Any CPU + {393B5B27-D15C-4F77-9457-FA14BA8F3C73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {393B5B27-D15C-4F77-9457-FA14BA8F3C73}.Debug|Any CPU.Build.0 = Debug|Any CPU + {393B5B27-D15C-4F77-9457-FA14BA8F3C73}.Release|Any CPU.ActiveCfg = Release|Any CPU + {393B5B27-D15C-4F77-9457-FA14BA8F3C73}.Release|Any CPU.Build.0 = Release|Any CPU + {06882742-27A6-4347-97D9-56162CEC9C11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06882742-27A6-4347-97D9-56162CEC9C11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06882742-27A6-4347-97D9-56162CEC9C11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06882742-27A6-4347-97D9-56162CEC9C11}.Release|Any CPU.Build.0 = Release|Any CPU + {2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.Build.0 = Release|Any CPU + {9F1AA3DE-962F-469B-82B2-46F93491389B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F1AA3DE-962F-469B-82B2-46F93491389B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F1AA3DE-962F-469B-82B2-46F93491389B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F1AA3DE-962F-469B-82B2-46F93491389B}.Release|Any CPU.Build.0 = Release|Any CPU + {E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Release|Any CPU.Build.0 = Release|Any CPU + {FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Release|Any CPU.Build.0 = Release|Any CPU + {AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Release|Any CPU.Build.0 = Release|Any CPU + {7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Release|Any CPU.Build.0 = Release|Any CPU + {959D01B4-5EF8-4D4E-BE06-AB1A580B0B52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {959D01B4-5EF8-4D4E-BE06-AB1A580B0B52}.Debug|Any CPU.Build.0 = Debug|Any CPU + {959D01B4-5EF8-4D4E-BE06-AB1A580B0B52}.Release|Any CPU.ActiveCfg = Release|Any CPU + {959D01B4-5EF8-4D4E-BE06-AB1A580B0B52}.Release|Any CPU.Build.0 = Release|Any CPU + {111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Release|Any CPU.Build.0 = Release|Any CPU + {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.Build.0 = Release|Any CPU + {0807616A-A77A-4B08-A65A-1582B09E114B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0807616A-A77A-4B08-A65A-1582B09E114B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0807616A-A77A-4B08-A65A-1582B09E114B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0807616A-A77A-4B08-A65A-1582B09E114B}.Release|Any CPU.Build.0 = Release|Any CPU + {DF72740C-900A-45DA-A3A6-4DDD68F286F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF72740C-900A-45DA-A3A6-4DDD68F286F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF72740C-900A-45DA-A3A6-4DDD68F286F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF72740C-900A-45DA-A3A6-4DDD68F286F2}.Release|Any CPU.Build.0 = Release|Any CPU + {74D02251-898E-4CAF-80C7-801820622903}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74D02251-898E-4CAF-80C7-801820622903}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74D02251-898E-4CAF-80C7-801820622903}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74D02251-898E-4CAF-80C7-801820622903}.Release|Any CPU.Build.0 = Release|Any CPU + {9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Release|Any CPU.Build.0 = Release|Any CPU + {2CAAD73E-E2F9-4888-B04A-3F3803DABDAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CAAD73E-E2F9-4888-B04A-3F3803DABDAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CAAD73E-E2F9-4888-B04A-3F3803DABDAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CAAD73E-E2F9-4888-B04A-3F3803DABDAE}.Release|Any CPU.Build.0 = Release|Any CPU + {1306F62D-CDAC-4269-982A-2EED51F0E318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1306F62D-CDAC-4269-982A-2EED51F0E318}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1306F62D-CDAC-4269-982A-2EED51F0E318}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1306F62D-CDAC-4269-982A-2EED51F0E318}.Release|Any CPU.Build.0 = Release|Any CPU + {1255D9BA-CE6E-42E4-A253-6376540B9661}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1255D9BA-CE6E-42E4-A253-6376540B9661}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1255D9BA-CE6E-42E4-A253-6376540B9661}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1255D9BA-CE6E-42E4-A253-6376540B9661}.Release|Any CPU.Build.0 = Release|Any CPU + {35803735-B669-4090-9681-CC7F7FABDC71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35803735-B669-4090-9681-CC7F7FABDC71}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35803735-B669-4090-9681-CC7F7FABDC71}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35803735-B669-4090-9681-CC7F7FABDC71}.Release|Any CPU.Build.0 = Release|Any CPU + {5A7681A5-60D9-480B-9AC7-63E0812A2548}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A7681A5-60D9-480B-9AC7-63E0812A2548}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A7681A5-60D9-480B-9AC7-63E0812A2548}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A7681A5-60D9-480B-9AC7-63E0812A2548}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {8BD8E012-F44F-4EE2-A234-D66C14D5FE4B} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} + {1AE65B61-9C05-4C80-ABFF-48F16E22FDF1} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} + {59A10DF3-63EC-43F1-A3BF-4000CFA118D2} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E} + {4ABB61D3-4959-4F09-883A-9EDC8CE473FB} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} + {C2C89551-44FD-41E4-80D3-69AF8CE3F174} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} + {4CDE10DD-60EC-4CCA-99D1-75224A201C89} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} + {7BD02E29-3430-4D06-88D2-5CECEE9ABD01} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} + {393B5B27-D15C-4F77-9457-FA14BA8F3C73} = {41CDCC73-9B81-49DD-9570-C54406E852AF} + {06882742-27A6-4347-97D9-56162CEC9C11} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249} + {2E1F5DB4-40CC-4804-A893-5DCE0193E598} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249} + {9F1AA3DE-962F-469B-82B2-46F93491389B} = {F61184E7-2426-4A13-ACEF-5689928E2CE2} + {E874D000-AD3A-4629-AC65-7219C2C7C1F0} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD} + {FF12ADA0-8975-4E67-B6EA-4AC82E0C8994} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD} + {AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD} + {7EA01F9C-E579-4B01-A3B9-733B49DD0B60} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} + {959D01B4-5EF8-4D4E-BE06-AB1A580B0B52} = {F61184E7-2426-4A13-ACEF-5689928E2CE2} + {111420E2-D4F0-4068-B46A-C4B6DCC823DC} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD} + {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6} = {8679CAC8-9164-4007-BDD2-F004810EDA14} + {0807616A-A77A-4B08-A65A-1582B09E114B} = {8679CAC8-9164-4007-BDD2-F004810EDA14} + {DF72740C-900A-45DA-A3A6-4DDD68F286F2} = {F61184E7-2426-4A13-ACEF-5689928E2CE2} + {74D02251-898E-4CAF-80C7-801820622903} = {F61184E7-2426-4A13-ACEF-5689928E2CE2} + {9E951521-2587-4FC6-AD26-FAA9179FB6C4} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1} + {2CAAD73E-E2F9-4888-B04A-3F3803DABDAE} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1} + {1306F62D-CDAC-4269-982A-2EED51F0E318} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1} + {1255D9BA-CE6E-42E4-A253-6376540B9661} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1} + {35803735-B669-4090-9681-CC7F7FABDC71} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} + {5A7681A5-60D9-480B-9AC7-63E0812A2548} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9} + EndGlobalSection +EndGlobal diff --git a/LibationSearchEngine/LibationSearchEngine.csproj b/LibationSearchEngine/LibationSearchEngine.csproj new file mode 100644 index 00000000..bf375dab --- /dev/null +++ b/LibationSearchEngine/LibationSearchEngine.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.1 + + + + + + + + + + + + + diff --git a/LibationSearchEngine/UNTESTED/LuceneExtensions.cs b/LibationSearchEngine/UNTESTED/LuceneExtensions.cs new file mode 100644 index 00000000..430a50c7 --- /dev/null +++ b/LibationSearchEngine/UNTESTED/LuceneExtensions.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Lucene.Net.Analysis; +using Lucene.Net.Documents; +using Lucene.Net.QueryParsers; +using Lucene.Net.Search; + +namespace LibationSearchEngine +{ + // field names are case specific and, due to StandardAnalyzer, content is case INspecific + public static class LuceneExtensions + { + public static void AddRaw(this Document document, string name, string value) + => document.Add(new Field(name, value, Field.Store.YES, Field.Index.NOT_ANALYZED)); + + public static void AddAnalyzed(this Document document, string name, string value) + { + if (value != null) + document.Add(new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.ANALYZED)); + } + + public static void AddNotAnalyzed(this Document document, string name, string value) + => document.Add(new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.NOT_ANALYZED)); + + public static void AddBool(this Document document, string name, bool value) + => document.Add(new Field(name.ToLowerInvariant(), value.ToString(), Field.Store.YES, Field.Index.ANALYZED_NO_NORMS)); + + public static Query GetQuery(this Analyzer analyzer, string defaultField, string searchString) + => new QueryParser(SearchEngine.Version, defaultField.ToLowerInvariant(), analyzer).Parse(searchString); + + // put all numbers, including dates, into this format: + // ########.## + internal const int PAD_DIGITS = 8; + internal const string DECIMAL_PRECISION = ".00"; + internal static string ToLuceneString(this int i) => ((double)i).ToLuceneString(); + internal static string ToLuceneString(this float f) => ((double)f).ToLuceneString(); + internal static string ToLuceneString(this DateTime dt) + => dt.ToString("yyyyMMdd") + DECIMAL_PRECISION; + internal static string ToLuceneString(this double d) + => d.ToString("0" + DECIMAL_PRECISION).PadLeft(PAD_DIGITS + DECIMAL_PRECISION.Length, '0'); + } +} diff --git a/LibationSearchEngine/UNTESTED/LuceneRegex.cs b/LibationSearchEngine/UNTESTED/LuceneRegex.cs new file mode 100644 index 00000000..3eab17c6 --- /dev/null +++ b/LibationSearchEngine/UNTESTED/LuceneRegex.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace LibationSearchEngine +{ + internal static class LuceneRegex + { + #region pattern pieces + // negative lookbehind: cannot be preceeded by an escaping \ + const string NOT_ESCAPED = @"(? $@"\{c}").Aggregate((a, b) => a + b); + private static string WORD_CAPTURE { get; } = $@"([^\s{disallowedCharsEscaped}]+)"; + + // : with optional preceeding spaces. capture these so i don't accidentally replace a non-field name + const string FIELD_END = @"(\s*:)"; + + const string BEGIN_TAG = @"\["; + const string END_TAG = @"\]"; + + // space is forgiven at beginning and end of tag but not in the middle + // literal space character only. do NOT allow new lines, tabs, ... + const string OPTIONAL_SPACE_LITERAL = @"\u0020*"; + #endregion + + private static string tagPattern { get; } = NOT_ESCAPED + BEGIN_TAG + OPTIONAL_SPACE_LITERAL + WORD_CAPTURE + OPTIONAL_SPACE_LITERAL + END_TAG; + public static Regex TagRegex { get; } = new Regex(tagPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); + + private static string fieldPattern { get; } = NOT_ESCAPED + WORD_CAPTURE + FIELD_END; + public static Regex FieldRegex { get; } = new Regex(fieldPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); + + // auto-pad numbers to 8 char.s. This will match int.s and dates (yyyyMMdd) + // positive look behind: beginning space { [ : + // positive look ahead: end space ] } + public static Regex NumbersRegex { get; } = new Regex(@"(?<=^|\s|\{|\[|:)(\d+\.?\d*)(?=$|\s|\]|\})", RegexOptions.Compiled); + } +} diff --git a/LibationSearchEngine/UNTESTED/SearchEngine.cs b/LibationSearchEngine/UNTESTED/SearchEngine.cs new file mode 100644 index 00000000..de81cf7c --- /dev/null +++ b/LibationSearchEngine/UNTESTED/SearchEngine.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using DataLayer; +using Dinah.Core; +using FileManager; +using Lucene.Net.Analysis.Standard; +using Lucene.Net.Documents; +using Lucene.Net.Index; +using Lucene.Net.Search; +using Lucene.Net.Store; + +namespace LibationSearchEngine +{ + public class SearchEngine + { + public const Lucene.Net.Util.Version Version = Lucene.Net.Util.Version.LUCENE_30; + + // not customizable. don't move to config + private static string SearchEngineDirectory { get; } + = new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("SearchEngine").FullName; + + public const string _ID_ = "_ID_"; + public const string TAGS = "tags"; + public const string ALL = "all"; + + private static ReadOnlyDictionary> idIndexRules { get; } + = new ReadOnlyDictionary>( + new Dictionary> + { + [nameof(Book.AudibleProductId)] = lb => lb.Book.AudibleProductId, + ["ProductId"] = lb => lb.Book.AudibleProductId, + ["Id"] = lb => lb.Book.AudibleProductId, + ["ASIN"] = lb => lb.Book.AudibleProductId + } + ); + private static ReadOnlyDictionary> stringIndexRules { get; } + = new ReadOnlyDictionary>( + new Dictionary> + { + [nameof(LibraryBook.DateAdded)] = lb => lb.DateAdded.ToLuceneString(), + [nameof(Book.DatePublished)] = lb => lb.Book.DatePublished?.ToLuceneString(), + + [nameof(Book.Title)] = lb => lb.Book.Title, + [nameof(Book.AuthorNames)] = lb => lb.Book.AuthorNames, + ["Author"] = lb => lb.Book.AuthorNames, + ["Authors"] = lb => lb.Book.AuthorNames, + [nameof(Book.NarratorNames)] = lb => lb.Book.NarratorNames, + ["Narrator"] = lb => lb.Book.NarratorNames, + ["Narrators"] = lb => lb.Book.NarratorNames, + [nameof(Book.Publisher)] = lb => lb.Book.Publisher, + + [nameof(Book.SeriesNames)] = lb => string.Join( + ", ", + lb.Book.SeriesLink + .Where(s => !string.IsNullOrWhiteSpace(s.Series.Name)) + .Select(s => s.Series.AudibleSeriesId)), + ["Series"] = lb => string.Join( + ", ", + lb.Book.SeriesLink + .Where(s => !string.IsNullOrWhiteSpace(s.Series.Name)) + .Select(s => s.Series.AudibleSeriesId)), + ["SeriesId"] = lb => string.Join(", ", lb.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId)), + + [nameof(Book.CategoriesNames)] = lb => lb.Book.CategoriesIds == null ? null : string.Join(", ", lb.Book.CategoriesIds), + [nameof(Book.Category)] = lb => lb.Book.CategoriesIds == null ? null : string.Join(", ", lb.Book.CategoriesIds), + ["Categories"] = lb => lb.Book.CategoriesIds == null ? null : string.Join(", ", lb.Book.CategoriesIds), + ["CategoriesId"] = lb => lb.Book.CategoriesIds == null ? null : string.Join(", ", lb.Book.CategoriesIds), + ["CategoryId"] = lb => lb.Book.CategoriesIds == null ? null : string.Join(", ", lb.Book.CategoriesIds), + + [TAGS.FirstCharToUpper()] = lb => lb.Book.UserDefinedItem.Tags + } + ); + + private static ReadOnlyDictionary> numberIndexRules { get; } + = new ReadOnlyDictionary>( + new Dictionary> + { + // for now, all numbers are padded to 8 char.s + // This will allow a single method to auto-pad numbers. The method will match these as well as date: yyyymmdd + [nameof(Book.LengthInMinutes)] = lb => lb.Book.LengthInMinutes.ToLuceneString(), + ["Length"] = lb => lb.Book.LengthInMinutes.ToLuceneString(), + ["Minutes"] = lb => lb.Book.LengthInMinutes.ToLuceneString(), + ["Hours"] = lb => (lb.Book.LengthInMinutes / 60).ToLuceneString(), + + ["ProductRating"] = lb => lb.Book.Rating.OverallRating.ToLuceneString(), + ["Rating"] = lb => lb.Book.Rating.OverallRating.ToLuceneString(), + ["UserRating"] = lb => lb.Book.UserDefinedItem.Rating.OverallRating.ToLuceneString(), + ["MyRating"] = lb => lb.Book.UserDefinedItem.Rating.OverallRating.ToLuceneString() + } + ); + private static ReadOnlyDictionary> boolIndexRules { get; } + = new ReadOnlyDictionary>( + new Dictionary> + { + ["HasDownloads"] = lb => lb.Book.Supplements.Any(), + ["HasDownload"] = lb => lb.Book.Supplements.Any(), + ["Downloads"] = lb => lb.Book.Supplements.Any(), + ["Download"] = lb => lb.Book.Supplements.Any(), + ["HasPDFs"] = lb => lb.Book.Supplements.Any(), + ["HasPDF"] = lb => lb.Book.Supplements.Any(), + ["PDFs"] = lb => lb.Book.Supplements.Any(), + ["PDF"] = lb => lb.Book.Supplements.Any(), + ["IsRated"] = lb => lb.Book.UserDefinedItem.Rating.OverallRating > 0f, + ["Rated"] = lb => lb.Book.UserDefinedItem.Rating.OverallRating > 0f, + ["IsAuthorNarrated"] = lb => lb.Book.Authors.Intersect(lb.Book.Narrators).Any(), + ["AuthorNarrated"] = lb => lb.Book.Authors.Intersect(lb.Book.Narrators).Any(), + [nameof(Book.IsAbridged)] = lb => lb.Book.IsAbridged, + ["Abridged"] = lb => lb.Book.IsAbridged, + }); + + // use these common fields in the "all" default search field + private static IEnumerable> allFieldIndexRules { get; } + = new List> + { + idIndexRules[nameof(Book.AudibleProductId)], + stringIndexRules[nameof(Book.Title)], + stringIndexRules[nameof(Book.AuthorNames)], + stringIndexRules[nameof(Book.NarratorNames)] + }; + + public static IEnumerable GetSearchIdFields() + { + foreach (var key in idIndexRules.Keys) + yield return key; + } + + public static IEnumerable GetSearchStringFields() + { + foreach (var key in stringIndexRules.Keys) + yield return key; + } + + public static IEnumerable GetSearchBoolFields() + { + foreach (var key in boolIndexRules.Keys) + yield return key; + } + + public static IEnumerable GetSearchNumberFields() + { + foreach (var key in numberIndexRules.Keys) + yield return key; + } + + public static IEnumerable GetSearchFields() + { + foreach (var key in idIndexRules.Keys) + yield return key; + foreach (var key in stringIndexRules.Keys) + yield return key; + foreach (var key in boolIndexRules.Keys) + yield return key; + foreach (var key in numberIndexRules.Keys) + yield return key; + } + + private Directory getIndex() => FSDirectory.Open(SearchEngineDirectory); + + public async Task CreateNewIndexAsync() => await Task.Run(() => createNewIndex(true)); + private void createNewIndex(bool overwrite) + { + // 300 products + // 1st run after app is started: 400ms + // subsequent runs: 200ms + var sw = System.Diagnostics.Stopwatch.StartNew(); + var stamps = new List(); + void log() => stamps.Add(sw.ElapsedMilliseconds); + + + log(); + + var library = LibraryQueries.GetLibrary_Flat_NoTracking(); + + log(); + + // location of index/create the index + using (var index = getIndex()) + { + var exists = IndexReader.IndexExists(index); + var createNewIndex = overwrite || !exists; + + // analyzer for tokenizing text. same analyzer should be used for indexing and searching + using (var analyzer = new StandardAnalyzer(Version)) + using (var ixWriter = new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED)) + { + foreach (var libraryBook in library) + { + var doc = createBookIndexDocument(libraryBook); + ixWriter.AddDocument(doc); + } + + // don't optimize. deprecated: ixWriter.Optimize(); + // ixWriter.Commit(); not needed if we're about to dispose of writer anyway. could be needed within the using() block + } + } + + log(); + } + + private static Document createBookIndexDocument(LibraryBook libraryBook) + { + var doc = new Document(); + + // refine with + // http://codeclimber.net.nz/archive/2009/09/10/how-subtext-lucenenet-index-is-structured/ + + // fields are key value pairs and MULTIPLE FIELDS CAN HAVE THE SAME KEY. + // splitting authors and narrators and/or tags into multiple fields could be interesting research. + // it could allow for more advanced searches, or maybe it could break broad searches. + + // all searching should be lowercase + // external callers have the reasonable expectation that product id will be returned CASE SPECIFIC + doc.AddRaw(_ID_, libraryBook.Book.AudibleProductId); + + // concat all common fields for the default 'all' field + var allConcat = + allFieldIndexRules + .Select(rule => rule(libraryBook)) + .Aggregate((a, b) => $"{a} {b}"); + doc.AddAnalyzed(ALL, allConcat); + + foreach (var kvp in idIndexRules) + doc.AddNotAnalyzed(kvp.Key, kvp.Value(libraryBook)); + + foreach (var kvp in stringIndexRules) + doc.AddAnalyzed(kvp.Key, kvp.Value(libraryBook)); + + foreach (var kvp in boolIndexRules) + doc.AddBool(kvp.Key, kvp.Value(libraryBook)); + + foreach (var kvp in numberIndexRules) + doc.AddNotAnalyzed(kvp.Key, kvp.Value(libraryBook)); + + return doc; + } + + public async Task UpdateBookAsync(string productId) => await Task.Run(() => updateBook(productId)); + private void updateBook(string productId) + { + var libraryBook = LibraryQueries.GetLibraryBook_Flat_NoTracking(productId); + var term = new Term(_ID_, productId); + + var document = createBookIndexDocument(libraryBook); + var createNewIndex = false; + + using (var index = getIndex()) + using (var analyzer = new StandardAnalyzer(Version)) + using (var ixWriter = new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED)) + { + ixWriter.DeleteDocuments(term); + ixWriter.AddDocument(document); + } + } + + public void UpdateTags(string productId, string tags) + { + var productTerm = new Term(_ID_, productId); + + using (var index = getIndex()) + { + Document document; + + // get existing document + using (var searcher = new IndexSearcher(index)) + { + var query = new TermQuery(productTerm); + var docs = searcher.Search(query, 1); + var scoreDoc = docs.ScoreDocs.SingleOrDefault(); + if (scoreDoc == null) + throw new Exception("document not found"); + document = searcher.Doc(scoreDoc.Doc); + } + + // update document entry with new tags + // fields are key value pairs and MULTIPLE FIELDS CAN HAVE THE SAME KEY. must remove old before adding new + // REMEMBER: all fields, including 'tags' are case-specific + document.RemoveField(TAGS); + document.AddAnalyzed(TAGS, tags); + + // update index + var createNewIndex = false; + using (var analyzer = new StandardAnalyzer(Version)) + using (var ixWriter = new IndexWriter(index, analyzer, createNewIndex, IndexWriter.MaxFieldLength.UNLIMITED)) + ixWriter.UpdateDocument(productTerm, document, analyzer); + } + } + + public SearchResultSet Search(string searchString) + { + if (string.IsNullOrWhiteSpace(searchString)) + searchString = "*:*"; + + #region apply formatting + searchString = parseTag(searchString); + + searchString = replaceBools(searchString); + + // in ranges " TO " must be uppercase + searchString = searchString.Replace(" to ", " TO "); + + searchString = padNumbers(searchString); + + searchString = lowerFieldNames(searchString); + #endregion + + var results = generalSearch(searchString); + + displayResults(results); + + return results; + } + + private static string parseTag(string tagSearchString) + { + var allMatches = LuceneRegex + .TagRegex + .Matches(tagSearchString) + .Cast() + .Select(a => a.ToString()) + .ToList(); + foreach (var match in allMatches) + tagSearchString = tagSearchString.Replace( + match, + TAGS + ":" + match.Trim('[', ']').Trim() + ); + + return tagSearchString; + } + + private static string replaceBools(string searchString) + { + // negative look-ahead for optional spaces then colon. don't want to double-up. eg:"israted:false" => "israted:false:True" + foreach (var boolSearch in boolIndexRules.Keys) + searchString = Regex.Replace(searchString, $@"\b({boolSearch})\b(?!\s*:)", @"$1:True", RegexOptions.IgnoreCase); + + return searchString; + } + + private static string padNumbers(string searchString) + { + var matches = LuceneRegex + .NumbersRegex + .Matches(searchString) + .Cast() + .OrderByDescending(m => m.Index); + + foreach (var m in matches) + { + var replaceString = double.Parse(m.ToString()).ToLuceneString(); + searchString = LuceneRegex.NumbersRegex.Replace(searchString, replaceString, 1, m.Index); + } + + return searchString; + } + + private static string lowerFieldNames(string searchString) + { + // fields are case specific + var allMatches = LuceneRegex + .FieldRegex + .Matches(searchString) + .Cast() + .Select(a => a.ToString()) + .ToList(); + + foreach (var match in allMatches) + searchString = searchString.Replace(match, match.ToLowerInvariant()); + + return searchString; + } + + public int MaxSearchResultsToReturn { get; set; } = 999; + + private SearchResultSet generalSearch(string searchString) + { + Console.WriteLine($"searchString: {searchString}"); + + var defaultField = ALL; + + using (var index = getIndex()) + using (var searcher = new IndexSearcher(index)) + using (var analyzer = new StandardAnalyzer(Version)) + { + var query = analyzer.GetQuery(defaultField, searchString); + + + // lucene doesn't allow only negations. eg this returns nothing: + // -tags:hidden + // work arounds: https://kb.ucla.edu/articles/pure-negation-query-in-lucene + // HOWEVER, doing this to any other type of query can cause EVERYTHING to be a match unless "Occur" is carefully set + // this should really check that all leaf nodes are MUST_NOT + if (query is BooleanQuery boolQuery) + { + var occurs = getOccurs_recurs(boolQuery); + if (occurs.Any() && occurs.All(o => o == Occur.MUST_NOT)) + boolQuery.Add(new MatchAllDocsQuery(), Occur.MUST); + } + + Console.WriteLine($" query: {query}"); + + var docs = searcher + .Search(query, MaxSearchResultsToReturn) + .ScoreDocs + .Select(ds => new ScoreDocExplicit(searcher.Doc(ds.Doc), ds.Score)) + .ToList(); + return new SearchResultSet(query.ToString(), docs); + } + } + + private IEnumerable getOccurs_recurs(BooleanQuery query) + { + var returnList = new List(); + + foreach (var clause in query) + { + returnList.Add(clause.Occur); + + if (clause.Query is BooleanQuery boolQuery) + returnList.AddRange(getOccurs_recurs(boolQuery)); + } + + return returnList; + } + + private void displayResults(SearchResultSet docs) + { + Console.WriteLine($"Hit(s): {docs.Docs.Count()}"); + //for (int i = 0; i < docs.Docs.Count(); i++) + //{ + // var sde = docs.Docs.First(); + + // Document doc = sde.Doc; + // float score = sde.Score; + + // Console.WriteLine($"{(i + 1)}) score={score}. Fields:"); + // var allFields = doc.GetFields(); + // foreach (var f in allFields) + // Console.WriteLine($" [{f.Name}]={f.StringValue}"); + //} + + //Console.WriteLine(); + } + } +} diff --git a/LibationSearchEngine/UNTESTED/SearchResults.cs b/LibationSearchEngine/UNTESTED/SearchResults.cs new file mode 100644 index 00000000..52f10853 --- /dev/null +++ b/LibationSearchEngine/UNTESTED/SearchResults.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Lucene.Net.Documents; + +namespace LibationSearchEngine +{ + public class SearchResultSet + { + public string SearchString { get; } + public IEnumerable Docs { get; } + public SearchResultSet(string searchString, IEnumerable docs) + { + SearchString = searchString; + Docs = docs; + } + } + public class ScoreDocExplicit + { + public Document Doc { get; } + public string ProductId { get; } + public float Score { get; } + + public ScoreDocExplicit(Document doc, float score) + { + Doc = doc; + ProductId = doc.GetField(SearchEngine._ID_).StringValue; + Score = score; + } + } +} diff --git a/LibationSearchEngine/_lucene resources.txt b/LibationSearchEngine/_lucene resources.txt new file mode 100644 index 00000000..612faaa8 --- /dev/null +++ b/LibationSearchEngine/_lucene resources.txt @@ -0,0 +1,5 @@ +series: http://codeclimber.net.nz/archive/2009/08/27/how-to-get-started-with-lucenenet/ +case study: http://codeclimber.net.nz/archive/2010/02/26/lucenenet-is-powering-subtext-25-search/ +series: http://www.codewrecks.com/blog/index.php/2012/08/25/index-your-blog-using-tags-and-lucene-net/ +query syntax: http://www.lucenetutorial.com/lucene-query-syntax.html +bool search: http://lucene.apache.org/core/7_3_1/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#Boolean_operators \ No newline at end of file diff --git a/LibationWinForm/LibationWinForm.csproj b/LibationWinForm/LibationWinForm.csproj new file mode 100644 index 00000000..8d8c5cbd --- /dev/null +++ b/LibationWinForm/LibationWinForm.csproj @@ -0,0 +1,32 @@ + + + + WinExe + netcoreapp3.0 + true + libation.ico + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + \ No newline at end of file diff --git a/LibationWinForm/Properties/DataSources/LibationWinForm.ProductGrids.GridEntry.datasource b/LibationWinForm/Properties/DataSources/LibationWinForm.ProductGrids.GridEntry.datasource new file mode 100644 index 00000000..3d47efff --- /dev/null +++ b/LibationWinForm/Properties/DataSources/LibationWinForm.ProductGrids.GridEntry.datasource @@ -0,0 +1,10 @@ + + + + LibationWinForm.ProductGrids.GridEntry, LibationWinForm, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null + \ No newline at end of file diff --git a/LibationWinForm/Properties/Resources.Designer.cs b/LibationWinForm/Properties/Resources.Designer.cs new file mode 100644 index 00000000..cf98ad7b --- /dev/null +++ b/LibationWinForm/Properties/Resources.Designer.cs @@ -0,0 +1,83 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace LibationWinForm.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LibationWinForm.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap edit_tags_25x25 { + get { + object obj = ResourceManager.GetObject("edit_tags_25x25", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap edit_tags_50x50 { + get { + object obj = ResourceManager.GetObject("edit_tags_50x50", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + } +} diff --git a/LibationWinForm/Properties/Resources.resx b/LibationWinForm/Properties/Resources.resx new file mode 100644 index 00000000..27f448c7 --- /dev/null +++ b/LibationWinForm/Properties/Resources.resx @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ..\Resources\edit-tags-25x25.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\edit-tags-50x50.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + \ No newline at end of file diff --git a/LibationWinForm/Resources/_icon how to.txt b/LibationWinForm/Resources/_icon how to.txt new file mode 100644 index 00000000..2156e766 --- /dev/null +++ b/LibationWinForm/Resources/_icon how to.txt @@ -0,0 +1,12 @@ +how to create the app's icon +============================ +https://www.flaticon.com/free-icon/glass-with-wine_33529#term=wine&page=1&position=59 +get this image, color=black, at each of these sizes: 16,32,64,128,256 + +from this answer: https://stackoverflow.com/a/16922387 +install image magick. in the install process, check legacy support (necessary for "convert") +go to the install dir: C:\Program Files\ImageMagick... +run the convert method from the stackoverflow example +run cmd +drag/drop convert.exe into cmd +... \ No newline at end of file diff --git a/LibationWinForm/Resources/edit-tags-25x25.png b/LibationWinForm/Resources/edit-tags-25x25.png new file mode 100644 index 00000000..82b24209 Binary files /dev/null and b/LibationWinForm/Resources/edit-tags-25x25.png differ diff --git a/LibationWinForm/Resources/edit-tags-50x50.png b/LibationWinForm/Resources/edit-tags-50x50.png new file mode 100644 index 00000000..7b0043ac Binary files /dev/null and b/LibationWinForm/Resources/edit-tags-50x50.png differ diff --git a/LibationWinForm/Resources/libation.ico b/LibationWinForm/Resources/libation.ico new file mode 100644 index 00000000..5fb77135 Binary files /dev/null and b/LibationWinForm/Resources/libation.ico differ diff --git a/LibationWinForm/UNTESTED/BookLiberation/AutomatedBackupsForm.Designer.cs b/LibationWinForm/UNTESTED/BookLiberation/AutomatedBackupsForm.Designer.cs new file mode 100644 index 00000000..c6f40c27 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/AutomatedBackupsForm.Designer.cs @@ -0,0 +1,93 @@ +namespace LibationWinForm.BookLiberation +{ + partial class AutomatedBackupsForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.keepGoingCb = new System.Windows.Forms.CheckBox(); + this.logTb = new System.Windows.Forms.TextBox(); + this.label1 = new System.Windows.Forms.Label(); + this.SuspendLayout(); + // + // keepGoingCb + // + this.keepGoingCb.AutoSize = true; + this.keepGoingCb.Checked = true; + this.keepGoingCb.CheckState = System.Windows.Forms.CheckState.Checked; + this.keepGoingCb.Location = new System.Drawing.Point(12, 12); + this.keepGoingCb.Name = "keepGoingCb"; + this.keepGoingCb.Size = new System.Drawing.Size(325, 17); + this.keepGoingCb.TabIndex = 0; + this.keepGoingCb.Text = "Keep going. Uncheck to stop when current backup is complete"; + this.keepGoingCb.UseVisualStyleBackColor = true; + // + // logTb + // + this.logTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.logTb.Location = new System.Drawing.Point(12, 48); + this.logTb.Multiline = true; + this.logTb.Name = "logTb"; + this.logTb.ReadOnly = true; + this.logTb.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.logTb.Size = new System.Drawing.Size(960, 202); + this.logTb.TabIndex = 1; + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Location = new System.Drawing.Point(9, 32); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(501, 13); + this.label1.TabIndex = 2; + this.label1.Text = "NOTE: if the working directories are inside of Dropbox, some book liberation acti" + + "ons may hang indefinitely"; + // + // AutomatedBackupsForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(984, 262); + this.Controls.Add(this.label1); + this.Controls.Add(this.logTb); + this.Controls.Add(this.keepGoingCb); + this.Name = "AutomatedBackupsForm"; + this.Text = "Automated Backups"; + this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.AutomatedBackupsForm_FormClosing); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.CheckBox keepGoingCb; + private System.Windows.Forms.TextBox logTb; + private System.Windows.Forms.Label label1; + } +} \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/BookLiberation/AutomatedBackupsForm.cs b/LibationWinForm/UNTESTED/BookLiberation/AutomatedBackupsForm.cs new file mode 100644 index 00000000..e3730922 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/AutomatedBackupsForm.cs @@ -0,0 +1,28 @@ +using System; +using System.Windows.Forms; +using Dinah.Core.Windows.Forms; + +namespace LibationWinForm.BookLiberation +{ + public partial class AutomatedBackupsForm : Form + { + public bool KeepGoingIsChecked => keepGoingCb.Checked; + + public AutomatedBackupsForm() + { + InitializeComponent(); + } + + public void AppendError(Exception ex) => AppendText("ERROR: " + ex.Message); + public void AppendText(string text) => logTb.UIThread(() => logTb.AppendText($"{DateTime.Now} {text}{Environment.NewLine}")); + + public void FinalizeUI() + { + keepGoingCb.Enabled = false; + logTb.AppendText(""); + AppendText("DONE"); + } + + private void AutomatedBackupsForm_FormClosing(object sender, FormClosingEventArgs e) => keepGoingCb.Checked = false; + } +} diff --git a/LibationWinForm/UNTESTED/BookLiberation/AutomatedBackupsForm.resx b/LibationWinForm/UNTESTED/BookLiberation/AutomatedBackupsForm.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/AutomatedBackupsForm.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/BookLiberation/DecryptForm.Designer.cs b/LibationWinForm/UNTESTED/BookLiberation/DecryptForm.Designer.cs new file mode 100644 index 00000000..16ba7f81 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/DecryptForm.Designer.cs @@ -0,0 +1,103 @@ +namespace LibationWinForm.BookLiberation +{ + partial class DecryptForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.pictureBox1 = new System.Windows.Forms.PictureBox(); + this.bookInfoLbl = new System.Windows.Forms.Label(); + this.progressBar1 = new System.Windows.Forms.ProgressBar(); + this.rtbLog = new System.Windows.Forms.RichTextBox(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).BeginInit(); + this.SuspendLayout(); + // + // pictureBox1 + // + this.pictureBox1.Location = new System.Drawing.Point(12, 12); + this.pictureBox1.Name = "pictureBox1"; + this.pictureBox1.Size = new System.Drawing.Size(100, 100); + this.pictureBox1.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.pictureBox1.TabIndex = 0; + this.pictureBox1.TabStop = false; + // + // bookInfoLbl + // + this.bookInfoLbl.AutoSize = true; + this.bookInfoLbl.Location = new System.Drawing.Point(118, 12); + this.bookInfoLbl.Name = "bookInfoLbl"; + this.bookInfoLbl.Size = new System.Drawing.Size(100, 13); + this.bookInfoLbl.TabIndex = 0; + this.bookInfoLbl.Text = "[multi-line book info]"; + // + // progressBar1 + // + this.progressBar1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.progressBar1.Location = new System.Drawing.Point(12, 526); + this.progressBar1.Name = "progressBar1"; + this.progressBar1.Size = new System.Drawing.Size(582, 23); + this.progressBar1.TabIndex = 2; + // + // rtbLog + // + this.rtbLog.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.rtbLog.Location = new System.Drawing.Point(12, 118); + this.rtbLog.Name = "rtbLog"; + this.rtbLog.Size = new System.Drawing.Size(582, 402); + this.rtbLog.TabIndex = 1; + this.rtbLog.Text = ""; + // + // DecryptForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(606, 561); + this.Controls.Add(this.rtbLog); + this.Controls.Add(this.progressBar1); + this.Controls.Add(this.bookInfoLbl); + this.Controls.Add(this.pictureBox1); + this.Name = "DecryptForm"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "DecryptForm"; + this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.DecryptForm_FormClosing); + this.Load += new System.EventHandler(this.DecryptForm_Load); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).EndInit(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.PictureBox pictureBox1; + private System.Windows.Forms.Label bookInfoLbl; + private System.Windows.Forms.ProgressBar progressBar1; + private System.Windows.Forms.RichTextBox rtbLog; + } +} \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/BookLiberation/DecryptForm.cs b/LibationWinForm/UNTESTED/BookLiberation/DecryptForm.cs new file mode 100644 index 00000000..c155ee23 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/DecryptForm.cs @@ -0,0 +1,70 @@ +using System; +using System.Windows.Forms; +using Dinah.Core.Drawing; +using Dinah.Core.IO; +using Dinah.Core.Windows.Forms; + +namespace LibationWinForm.BookLiberation +{ + public partial class DecryptForm : Form + { + public DecryptForm() + { + InitializeComponent(); + } + + System.IO.TextWriter origOut = Console.Out; + private void DecryptForm_Load(object sender, EventArgs e) + { + // redirect Console.WriteLine to console, textbox + System.IO.TextWriter origOut = Console.Out; + var controlWriter = new RichTextBoxTextWriter(this.rtbLog); + var multiLogger = new MultiTextWriter(origOut, controlWriter); + Console.SetOut(multiLogger); + } + + private void DecryptForm_FormClosing(object sender, FormClosingEventArgs e) + { + // restore original + Console.SetOut(origOut); + } + + // book info + string title; + string authorNames; + string narratorNames; + + public void SetTitle(string title) + { + this.UIThread(() => this.Text = " Decrypting " + title); + this.title = title; + updateBookInfo(); + } + public void SetAuthorNames(string authorNames) + { + this.authorNames = authorNames; + updateBookInfo(); + } + public void SetNarratorNames(string narratorNames) + { + this.narratorNames = narratorNames; + updateBookInfo(); + } + + // thread-safe UI updates + private void updateBookInfo() + => bookInfoLbl.UIThread(() => bookInfoLbl.Text = $"{title}\r\nBy {authorNames}\r\nNarrated by {narratorNames}"); + + public void SetCoverImage(byte[] coverBytes) + => pictureBox1.UIThread(() => pictureBox1.Image = ImageConverter.GetPictureFromBytes(coverBytes)); + + public void AppendError(Exception ex) => AppendText("ERROR: " + ex.Message); + public void AppendText(string text) => + // redirected to log textbox + Console.WriteLine($"{DateTime.Now} {text}") + //logTb.UIThread(() => logTb.AppendText($"{DateTime.Now} {text}{Environment.NewLine}")) + ; + + public void UpdateProgress(int percentage) => progressBar1.UIThread(() => progressBar1.Value = percentage); + } +} diff --git a/LibationWinForm/UNTESTED/BookLiberation/DecryptForm.resx b/LibationWinForm/UNTESTED/BookLiberation/DecryptForm.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/DecryptForm.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/BookLiberation/DownloadForm.Designer.cs b/LibationWinForm/UNTESTED/BookLiberation/DownloadForm.Designer.cs new file mode 100644 index 00000000..6e16e9eb --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/DownloadForm.Designer.cs @@ -0,0 +1,105 @@ +namespace LibationWinForm.BookLiberation +{ + partial class DownloadForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.filenameLbl = new System.Windows.Forms.Label(); + this.progressBar1 = new System.Windows.Forms.ProgressBar(); + this.progressLbl = new System.Windows.Forms.Label(); + this.lastUpdateLbl = new System.Windows.Forms.Label(); + this.SuspendLayout(); + // + // filenameLbl + // + this.filenameLbl.AutoSize = true; + this.filenameLbl.Location = new System.Drawing.Point(12, 9); + this.filenameLbl.Name = "filenameLbl"; + this.filenameLbl.Size = new System.Drawing.Size(52, 13); + this.filenameLbl.TabIndex = 0; + this.filenameLbl.Text = "[filename]"; + // + // progressBar1 + // + this.progressBar1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.progressBar1.Location = new System.Drawing.Point(15, 67); + this.progressBar1.Name = "progressBar1"; + this.progressBar1.Size = new System.Drawing.Size(877, 23); + this.progressBar1.TabIndex = 4; + // + // progressLbl + // + this.progressLbl.Location = new System.Drawing.Point(12, 36); + this.progressLbl.Name = "progressLbl"; + this.progressLbl.Size = new System.Drawing.Size(173, 13); + this.progressLbl.TabIndex = 5; + this.progressLbl.Text = "[2,999,999,999] of [2,999,999,999]"; + this.progressLbl.TextAlign = System.Drawing.ContentAlignment.TopRight; + // + // lastUpdateLbl + // + this.lastUpdateLbl.AutoSize = true; + this.lastUpdateLbl.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(0))); + this.lastUpdateLbl.ForeColor = System.Drawing.Color.DarkRed; + this.lastUpdateLbl.Location = new System.Drawing.Point(361, 36); + this.lastUpdateLbl.Name = "lastUpdateLbl"; + this.lastUpdateLbl.Size = new System.Drawing.Size(81, 13); + this.lastUpdateLbl.TabIndex = 6; + this.lastUpdateLbl.Text = "Last updated"; + this.lastUpdateLbl.Visible = false; + // + // DownloadForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(904, 102); + this.Controls.Add(this.lastUpdateLbl); + this.Controls.Add(this.progressLbl); + this.Controls.Add(this.progressBar1); + this.Controls.Add(this.filenameLbl); + this.MaximizeBox = false; + this.MinimizeBox = false; + this.Name = "DownloadForm"; + this.ShowIcon = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Downloading"; + this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.DownloadForm_FormClosing); + this.Load += new System.EventHandler(this.DownloadForm_Load); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Label filenameLbl; + private System.Windows.Forms.ProgressBar progressBar1; + private System.Windows.Forms.Label progressLbl; + private System.Windows.Forms.Label lastUpdateLbl; + } +} \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/BookLiberation/DownloadForm.cs b/LibationWinForm/UNTESTED/BookLiberation/DownloadForm.cs new file mode 100644 index 00000000..82ea91a9 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/DownloadForm.cs @@ -0,0 +1,59 @@ +using System; +using System.Windows.Forms; +using Dinah.Core.Windows.Forms; + +namespace LibationWinForm.BookLiberation +{ + public partial class DownloadForm : Form + { + public DownloadForm() + { + InitializeComponent(); + + progressLbl.Text = ""; + filenameLbl.Text = ""; + } + + // thread-safe UI updates + public void UpdateFilename(string title) => filenameLbl.UIThread(() => filenameLbl.Text = title); + + public void DownloadProgressChanged(long BytesReceived, long TotalBytesToReceive) + { + // this won't happen with download file. it will happen with download string + if (TotalBytesToReceive < 0) + return; + + progressLbl.UIThread(() => progressLbl.Text = $"{BytesReceived:#,##0} of {TotalBytesToReceive:#,##0}"); + + var d = double.Parse(BytesReceived.ToString()) / double.Parse(TotalBytesToReceive.ToString()) * 100.0; + var i = int.Parse(Math.Truncate(d).ToString()); + progressBar1.UIThread(() => progressBar1.Value = i); + + lastDownloadProgress = DateTime.Now; + } + + #region timer + Timer timer = new Timer { Interval = 1000 }; + private void DownloadForm_Load(object sender, EventArgs e) + { + timer.Tick += new EventHandler(timer_Tick); + timer.Start(); + } + DateTime lastDownloadProgress = DateTime.Now; + private void timer_Tick(object sender, EventArgs e) + { + // if no update in the last 30 seconds, display frozen label + lastUpdateLbl.Visible = lastDownloadProgress.AddSeconds(30) < DateTime.Now; + if (lastUpdateLbl.Visible) + { + var diff = lastDownloadProgress - DateTime.Now; + var min = (int)diff.TotalMinutes; + var minText = min > 0 ? $"{min}min " : ""; + + lastUpdateLbl.Text = $"Frozen? Last download activity: {minText}{diff.Seconds}sec ago"; + } + } + private void DownloadForm_FormClosing(object sender, FormClosingEventArgs e) => timer.Stop(); + #endregion + } +} diff --git a/LibationWinForm/UNTESTED/BookLiberation/DownloadForm.resx b/LibationWinForm/UNTESTED/BookLiberation/DownloadForm.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/DownloadForm.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/BookLiberation/NoLongerAvailableForm.Designer.cs b/LibationWinForm/UNTESTED/BookLiberation/NoLongerAvailableForm.Designer.cs new file mode 100644 index 00000000..72d94470 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/NoLongerAvailableForm.Designer.cs @@ -0,0 +1,129 @@ +namespace LibationWinForm.BookLiberation +{ + partial class NoLongerAvailableForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.label1 = new System.Windows.Forms.Label(); + this.textBox1 = new System.Windows.Forms.TextBox(); + this.missingBtn = new System.Windows.Forms.Button(); + this.abortBtn = new System.Windows.Forms.Button(); + this.label2 = new System.Windows.Forms.Label(); + this.label3 = new System.Windows.Forms.Label(); + this.SuspendLayout(); + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Location = new System.Drawing.Point(12, 9); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(174, 39); + this.label1.TabIndex = 0; + this.label1.Text = "Book details download failed.\r\n{0} may be no longer available.\r\nVerify the book i" + + "s still available here"; + // + // textBox1 + // + this.textBox1.Location = new System.Drawing.Point(15, 51); + this.textBox1.Name = "textBox1"; + this.textBox1.ReadOnly = true; + this.textBox1.Size = new System.Drawing.Size(384, 20); + this.textBox1.TabIndex = 1; + // + // missingBtn + // + this.missingBtn.Location = new System.Drawing.Point(324, 77); + this.missingBtn.Name = "missingBtn"; + this.missingBtn.Size = new System.Drawing.Size(75, 23); + this.missingBtn.TabIndex = 3; + this.missingBtn.Text = "Missing"; + this.missingBtn.UseVisualStyleBackColor = true; + this.missingBtn.Click += new System.EventHandler(this.missingBtn_Click); + // + // abortBtn + // + this.abortBtn.Location = new System.Drawing.Point(324, 126); + this.abortBtn.Name = "abortBtn"; + this.abortBtn.Size = new System.Drawing.Size(75, 23); + this.abortBtn.TabIndex = 5; + this.abortBtn.Text = "Abort"; + this.abortBtn.UseVisualStyleBackColor = true; + this.abortBtn.Click += new System.EventHandler(this.abortBtn_Click); + // + // label2 + // + this.label2.AutoSize = true; + this.label2.Location = new System.Drawing.Point(12, 74); + this.label2.Name = "label2"; + this.label2.Size = new System.Drawing.Size(306, 26); + this.label2.TabIndex = 2; + this.label2.Text = "If the book is not available, click here to mark it as missing\r\nNo further book d" + + "etails download will be attempted for this book"; + // + // label3 + // + this.label3.AutoSize = true; + this.label3.Location = new System.Drawing.Point(12, 123); + this.label3.Name = "label3"; + this.label3.Size = new System.Drawing.Size(204, 26); + this.label3.TabIndex = 4; + this.label3.Text = "If the book is actually available, click here\r\nto abort and try again later"; + // + // NoLongerAvailableForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(411, 161); + this.Controls.Add(this.label3); + this.Controls.Add(this.label2); + this.Controls.Add(this.abortBtn); + this.Controls.Add(this.missingBtn); + this.Controls.Add(this.textBox1); + this.Controls.Add(this.label1); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + this.MaximizeBox = false; + this.MinimizeBox = false; + this.Name = "NoLongerAvailableForm"; + this.ShowIcon = false; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "No Longer Available"; + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Label label1; + private System.Windows.Forms.TextBox textBox1; + private System.Windows.Forms.Button missingBtn; + private System.Windows.Forms.Button abortBtn; + private System.Windows.Forms.Label label2; + private System.Windows.Forms.Label label3; + } +} \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/BookLiberation/NoLongerAvailableForm.cs b/LibationWinForm/UNTESTED/BookLiberation/NoLongerAvailableForm.cs new file mode 100644 index 00000000..ce720cf4 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/NoLongerAvailableForm.cs @@ -0,0 +1,28 @@ +using System; +using System.Windows.Forms; +using DomainServices; + +namespace LibationWinForm.BookLiberation +{ + public partial class NoLongerAvailableForm : Form + { + public ScrapeBookDetails.NoLongerAvailableEnum EnumResult { get; private set; } + + public NoLongerAvailableForm(string title, string url) : this() + { + this.Text += ": " + title; + this.label1.Text = string.Format(this.label1.Text, title); + this.textBox1.Text = url; + } + public NoLongerAvailableForm() => InitializeComponent(); + + private void missingBtn_Click(object sender, EventArgs e) => complete(ScrapeBookDetails.NoLongerAvailableEnum.MarkAsMissing); + private void abortBtn_Click(object sender, EventArgs e) => complete(ScrapeBookDetails.NoLongerAvailableEnum.Abort); + + private void complete(ScrapeBookDetails.NoLongerAvailableEnum nlaEnum) + { + EnumResult = nlaEnum; + Close(); + } + } +} diff --git a/LibationWinForm/UNTESTED/BookLiberation/NoLongerAvailableForm.resx b/LibationWinForm/UNTESTED/BookLiberation/NoLongerAvailableForm.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/NoLongerAvailableForm.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/BookLiberation/ProcessorAutomationController.Examples.cs b/LibationWinForm/UNTESTED/BookLiberation/ProcessorAutomationController.Examples.cs new file mode 100644 index 00000000..22fa7d17 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/ProcessorAutomationController.Examples.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DataLayer; +using DomainServices; + +namespace LibationWinForm.BookLiberation +{ + public class BookLiberatorControllerExamples + { + async Task BackupBookAsync(string productId) + { + LibraryBook libraryBook; + using (var context = LibationContext.Create()) + libraryBook = context + .Library + .GetLibrary() + .SingleOrDefault(lb => lb.Book.AudibleProductId == productId); + + if (libraryBook == null) + return; + + var backupBook = new BackupBook(); + backupBook.Download.Completed += SetBackupCountsAsync; + backupBook.Decrypt.Completed += SetBackupCountsAsync; + await backupBook.ProcessValidateLibraryBookAsync(libraryBook); + } + + // Download First Book (Download encrypted/DRM file) + async Task DownloadFirstBookAsync() + { + var downloadBook = ProcessorAutomationController.GetWiredUpDownloadBook(); + downloadBook.Completed += SetBackupCountsAsync; + await downloadBook.ProcessFirstValidAsync(); + } + + // Decrypt First Book (Remove DRM from downloaded file) + async Task DecryptFirstBookAsync() + { + var decryptBook = ProcessorAutomationController.GetWiredUpDecryptBook(); + decryptBook.Completed += SetBackupCountsAsync; + await decryptBook.ProcessFirstValidAsync(); + } + + // Backup First Book (Decrypt a non-liberated book. Download if needed) + async Task BackupFirstBookAsync() + { + var backupBook = ProcessorAutomationController.GetWiredUpBackupBook(); + backupBook.Download.Completed += SetBackupCountsAsync; + backupBook.Decrypt.Completed += SetBackupCountsAsync; + await backupBook.ProcessFirstValidAsync(); + } + + async void SetBackupCountsAsync(object obj, string str) => throw new NotImplementedException(); + } +} diff --git a/LibationWinForm/UNTESTED/BookLiberation/ProcessorAutomationController.cs b/LibationWinForm/UNTESTED/BookLiberation/ProcessorAutomationController.cs new file mode 100644 index 00000000..a8393174 --- /dev/null +++ b/LibationWinForm/UNTESTED/BookLiberation/ProcessorAutomationController.cs @@ -0,0 +1,284 @@ +using System; +using System.Threading.Tasks; +using DomainServices; + +namespace LibationWinForm.BookLiberation +{ + // matches a file processor with a form + public static class ProcessorAutomationController + { + // + // these utility methods ensure proper wiring + // 1) we can't forget to do it + // 2) we can't accidentally do it mult times becaues we lost track of complexity + // + public static BackupBook GetWiredUpBackupBook() + { + var backupBook = new BackupBook(); + + backupBook.Download.Begin += (_, __) => wireUpDownloadable(backupBook.Download); + backupBook.Decrypt.Begin += (_, __) => wireUpDecryptable(backupBook.Decrypt); + + return backupBook; + } + public static DecryptBook GetWiredUpDecryptBook() + { + var decryptBook = new DecryptBook(); + decryptBook.Begin += (_, __) => wireUpDecryptable(decryptBook); + return decryptBook; + } + public static DownloadBook GetWiredUpDownloadBook() + { + var downloadBook = new DownloadBook(); + downloadBook.Begin += (_, __) => wireUpDownloadable(downloadBook); + return downloadBook; + } + public static DownloadPdf GetWiredUpDownloadPdf() + { + var downloadPdf = new DownloadPdf(); + downloadPdf.Begin += (_, __) => wireUpDownloadable(downloadPdf); + return downloadPdf; + } + public static ScrapeBookDetails GetWiredUpScrapeBookDetails() + { + var scrapeBookDetails = new ScrapeBookDetails(); + scrapeBookDetails.Begin += (_, __) => wireUpDownloadable(scrapeBookDetails); + + scrapeBookDetails.NoLongerAvailableAction = noLongerAvailableUI; + + return scrapeBookDetails; + } + static ScrapeBookDetails.NoLongerAvailableEnum noLongerAvailableUI(string title, string url) + { + var nla = new NoLongerAvailableForm(title, url); + nla.ShowDialog(); + return nla.EnumResult; + } + + // subscribed to Begin event because a new form should be created+processed+closed on each iteration + private static void wireUpDownloadable(IDownloadable downloadable) + { + #region create form + var downloadDialog = new DownloadForm(); + #endregion + + // extra complexity for wiring up download form: + // case 1: download is needed + // dialog created. subscribe to events + // downloadable.DownloadBegin fires. shows dialog + // downloadable.DownloadCompleted fires. closes dialog. which fires FormClosing, FormClosed, Disposed + // Disposed unsubscribe from events + // case 2: download is not needed + // dialog created. subscribe to events + // dialog is never shown nor closed + // downloadable.Completed fires. disposes dialog and unsubscribes from events + + #region define how model actions will affect form behavior + void downloadBegin(object _, string str) + { + downloadDialog.UpdateFilename(str); + downloadDialog.Show(); + } + + // close form on DOWNLOAD completed, not final Completed. Else for BackupBook this form won't close until DECRYPT is also complete + void fileDownloadCompleted(object _, string __) => downloadDialog.Close(); + + void downloadProgressChanged(object _, System.Net.DownloadProgressChangedEventArgs arg) + => downloadDialog.DownloadProgressChanged(arg.BytesReceived, arg.TotalBytesToReceive); + + void unsubscribe(object _ = null, EventArgs __ = null) + { + downloadable.DownloadBegin -= downloadBegin; + downloadable.DownloadCompleted -= fileDownloadCompleted; + downloadable.DownloadProgressChanged -= downloadProgressChanged; + downloadable.Completed -= dialogDispose; + } + + // unless we dispose, if the form is created but un-used/never-shown then weird UI stuff can happen + // also, since event unsubscribe occurs on FormClosing and an unused form is never closed, then the events will never be unsubscribed + void dialogDispose(object _, string __) + { + if (!downloadDialog.IsDisposed) + downloadDialog.Dispose(); + } + #endregion + + #region subscribe new form to model's events + downloadable.DownloadBegin += downloadBegin; + downloadable.DownloadCompleted += fileDownloadCompleted; + downloadable.DownloadProgressChanged += downloadProgressChanged; + downloadable.Completed += dialogDispose; + #endregion + + #region when form closes, unsubscribe from model's events + // unsubscribe so disposed forms aren't still trying to receive notifications + // FormClosing is more UI safe but won't fire unless the form is shown and closed + // if form was shown, Disposed will fire for FormClosing, FormClosed, and Disposed + // if not shown, it will still fire for Disposed + downloadDialog.Disposed += unsubscribe; + #endregion + } + + // subscribed to Begin event because a new form should be created+processed+closed on each iteration + private static void wireUpDecryptable(IDecryptable decryptBook) + { + #region create form + var decryptDialog = new DecryptForm(); + #endregion + + #region define how model actions will affect form behavior + void decryptBegin(object _, string __) => decryptDialog.Show(); + + void titleDiscovered(object _, string title) => decryptDialog.SetTitle(title); + void authorsDiscovered(object _, string authors) => decryptDialog.SetAuthorNames(authors); + void narratorsDiscovered(object _, string narrators) => decryptDialog.SetNarratorNames(narrators); + void coverImageFilepathDiscovered(object _, byte[] coverBytes) => decryptDialog.SetCoverImage(coverBytes); + void updateProgress(object _, int percentage) => decryptDialog.UpdateProgress(percentage); + + void decryptCompleted(object _, string __) => decryptDialog.Close(); + #endregion + + #region subscribe new form to model's events + decryptBook.DecryptBegin += decryptBegin; + + decryptBook.TitleDiscovered += titleDiscovered; + decryptBook.AuthorsDiscovered += authorsDiscovered; + decryptBook.NarratorsDiscovered += narratorsDiscovered; + decryptBook.CoverImageFilepathDiscovered += coverImageFilepathDiscovered; + decryptBook.UpdateProgress += updateProgress; + + decryptBook.DecryptCompleted += decryptCompleted; + #endregion + + #region when form closes, unsubscribe from model's events + // unsubscribe so disposed forms aren't still trying to receive notifications + decryptDialog.FormClosing += (_, __) => + { + decryptBook.DecryptBegin -= decryptBegin; + + decryptBook.TitleDiscovered -= titleDiscovered; + decryptBook.AuthorsDiscovered -= authorsDiscovered; + decryptBook.NarratorsDiscovered -= narratorsDiscovered; + decryptBook.CoverImageFilepathDiscovered -= coverImageFilepathDiscovered; + decryptBook.UpdateProgress -= updateProgress; + + decryptBook.DecryptCompleted -= decryptCompleted; + }; + #endregion + } + + public static async Task RunAutomaticDownload(IDownloadable downloadable) + { + #region create form + var automatedBackupsForm = new AutomatedBackupsForm(); + #endregion + + #region define how model actions will affect form behavior + void begin(object _, string str) => automatedBackupsForm.AppendText("Begin: " + str); + void statusUpdate(object _, string str) => automatedBackupsForm.AppendText("- " + str); + // extra line after book is completely finished + void completed(object _, string str) => automatedBackupsForm.AppendText("Completed: " + str + Environment.NewLine); + #endregion + + #region subscribe new form to model's events + downloadable.Begin += begin; + downloadable.StatusUpdate += statusUpdate; + downloadable.Completed += completed; + #endregion + + #region when form closes, unsubscribe from model's events + // unsubscribe so disposed forms aren't still trying to receive notifications + automatedBackupsForm.FormClosing += (_, __) => + { + downloadable.Begin -= begin; + downloadable.StatusUpdate -= statusUpdate; + downloadable.Completed -= completed; + }; + #endregion + + await runBackupLoop(downloadable, automatedBackupsForm); + } + + public static async Task RunAutomaticBackup(BackupBook backupBook) + { + #region create form + var automatedBackupsForm = new AutomatedBackupsForm(); + #endregion + + #region define how model actions will affect form behavior + void downloadBegin(object _, string str) => automatedBackupsForm.AppendText("DownloadStep_Begin: " + str); + void statusUpdate(object _, string str) => automatedBackupsForm.AppendText("- " + str); + void downloadCompleted(object _, string str) => automatedBackupsForm.AppendText("DownloadStep_Completed: " + str); + void decryptBegin(object _, string str) => automatedBackupsForm.AppendText("DecryptStep_Begin: " + str); + // extra line after book is completely finished + void decryptCompleted(object _, string str) => automatedBackupsForm.AppendText("DecryptStep_Completed: " + str + Environment.NewLine); + #endregion + + #region subscribe new form to model's events + backupBook.Download.Begin += downloadBegin; + backupBook.Download.StatusUpdate += statusUpdate; + backupBook.Download.Completed += downloadCompleted; + backupBook.Decrypt.Begin += decryptBegin; + backupBook.Decrypt.StatusUpdate += statusUpdate; + backupBook.Decrypt.Completed += decryptCompleted; + #endregion + + #region when form closes, unsubscribe from model's events + // unsubscribe so disposed forms aren't still trying to receive notifications + automatedBackupsForm.FormClosing += (_, __) => + { + backupBook.Download.Begin -= downloadBegin; + backupBook.Download.StatusUpdate -= statusUpdate; + backupBook.Download.Completed -= downloadCompleted; + backupBook.Decrypt.Begin -= decryptBegin; + backupBook.Decrypt.StatusUpdate -= statusUpdate; + backupBook.Decrypt.Completed -= decryptCompleted; + }; + #endregion + + await runBackupLoop(backupBook, automatedBackupsForm); + } + + // automated backups looper feels like a composible IProcessable: logic, UI, begin + process child + end + // however the process step doesn't follow the pattern: Validate(product) + Process(product) + private static async Task runBackupLoop(IProcessable processable, AutomatedBackupsForm automatedBackupsForm) + { + automatedBackupsForm.Show(); + + try + { + do + { + var statusHandler = await processable.ProcessFirstValidAsync(); + + if (statusHandler == null) + { + automatedBackupsForm.AppendText("Done. All books have been processed"); + break; + } + + if (statusHandler.HasErrors) + { + automatedBackupsForm.AppendText("ERROR. All books have not been processed. Most recent valid book: processing failed"); + foreach (var errorMessage in statusHandler.Errors) + automatedBackupsForm.AppendText(errorMessage); + break; + } + + if (!automatedBackupsForm.KeepGoingIsChecked) + { + automatedBackupsForm.AppendText("'Keep going' is unchecked"); + break; + } + } + while (automatedBackupsForm.KeepGoingIsChecked); + } + catch (Exception ex) + { + automatedBackupsForm.AppendError(ex); + } + + automatedBackupsForm.FinalizeUI(); + } + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/EditQuickFilters.Designer.cs b/LibationWinForm/UNTESTED/Dialogs/EditQuickFilters.Designer.cs new file mode 100644 index 00000000..89f05718 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/EditQuickFilters.Designer.cs @@ -0,0 +1,152 @@ +namespace LibationWinForm.Dialogs +{ + partial class EditQuickFilters + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.cancelBtn = new System.Windows.Forms.Button(); + this.saveBtn = new System.Windows.Forms.Button(); + this.dataGridView1 = new System.Windows.Forms.DataGridView(); + this.Original = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.Delete = new System.Windows.Forms.DataGridViewButtonColumn(); + this.Filter = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.MoveUp = new System.Windows.Forms.DataGridViewButtonColumn(); + this.MoveDown = new System.Windows.Forms.DataGridViewButtonColumn(); + ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit(); + this.SuspendLayout(); + // + // cancelBtn + // + this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.cancelBtn.Location = new System.Drawing.Point(713, 415); + this.cancelBtn.Name = "cancelBtn"; + this.cancelBtn.Size = new System.Drawing.Size(75, 23); + this.cancelBtn.TabIndex = 2; + this.cancelBtn.Text = "Cancel"; + this.cancelBtn.UseVisualStyleBackColor = true; + this.cancelBtn.Click += new System.EventHandler(this.CancelBtn_Click); + // + // saveBtn + // + this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.saveBtn.Location = new System.Drawing.Point(612, 415); + this.saveBtn.Name = "saveBtn"; + this.saveBtn.Size = new System.Drawing.Size(75, 23); + this.saveBtn.TabIndex = 1; + this.saveBtn.Text = "Save"; + this.saveBtn.UseVisualStyleBackColor = true; + this.saveBtn.Click += new System.EventHandler(this.SaveBtn_Click); + // + // dataGridView1 + // + this.dataGridView1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.dataGridView1.AutoSizeColumnsMode = System.Windows.Forms.DataGridViewAutoSizeColumnsMode.AllCells; + this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dataGridView1.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { + this.Original, + this.Delete, + this.Filter, + this.MoveUp, + this.MoveDown}); + this.dataGridView1.Location = new System.Drawing.Point(12, 12); + this.dataGridView1.MultiSelect = false; + this.dataGridView1.Name = "dataGridView1"; + this.dataGridView1.Size = new System.Drawing.Size(776, 397); + this.dataGridView1.TabIndex = 0; + this.dataGridView1.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView1_CellContentClick); + // + // Original + // + this.Original.HeaderText = "Original"; + this.Original.Name = "Original"; + this.Original.ReadOnly = true; + this.Original.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable; + this.Original.Visible = false; + this.Original.Width = 48; + // + // Delete + // + this.Delete.HeaderText = "Delete"; + this.Delete.Name = "Delete"; + this.Delete.ReadOnly = true; + this.Delete.Text = "x"; + this.Delete.Width = 44; + // + // Filter + // + this.Filter.HeaderText = "Filter"; + this.Filter.Name = "Filter"; + this.Filter.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable; + this.Filter.Width = 35; + // + // MoveUp + // + this.MoveUp.HeaderText = "Move Up"; + this.MoveUp.Name = "MoveUp"; + this.MoveUp.ReadOnly = true; + this.MoveUp.Text = "^"; + this.MoveUp.Width = 57; + // + // MoveDown + // + this.MoveDown.HeaderText = "Move Down"; + this.MoveDown.Name = "MoveDown"; + this.MoveDown.ReadOnly = true; + this.MoveDown.Text = "v"; + this.MoveDown.Width = 71; + // + // EditQuickFilters + // + this.AcceptButton = this.saveBtn; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.CancelButton = this.cancelBtn; + this.ClientSize = new System.Drawing.Size(800, 450); + this.Controls.Add(this.dataGridView1); + this.Controls.Add(this.cancelBtn); + this.Controls.Add(this.saveBtn); + this.Name = "EditQuickFilters"; + this.Text = "Edit Quick Filters"; + ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit(); + this.ResumeLayout(false); + + } + + #endregion + private System.Windows.Forms.Button cancelBtn; + private System.Windows.Forms.Button saveBtn; + private System.Windows.Forms.DataGridView dataGridView1; + private System.Windows.Forms.DataGridViewTextBoxColumn Original; + private System.Windows.Forms.DataGridViewButtonColumn Delete; + private System.Windows.Forms.DataGridViewTextBoxColumn Filter; + private System.Windows.Forms.DataGridViewButtonColumn MoveUp; + private System.Windows.Forms.DataGridViewButtonColumn MoveDown; + } +} \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Dialogs/EditQuickFilters.cs b/LibationWinForm/UNTESTED/Dialogs/EditQuickFilters.cs new file mode 100644 index 00000000..784812f8 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/EditQuickFilters.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using FileManager; + +namespace LibationWinForm.Dialogs +{ + public partial class EditQuickFilters : Form + { + const string COL_Original = "Original"; + const string COL_Delete = "Delete"; + const string COL_Filter = "Filter"; + const string COL_MoveUp = "MoveUp"; + const string COL_MoveDown = "MoveDown"; + + Form1 _parent { get; } + + public EditQuickFilters(Form1 parent) + { + _parent = parent; + + InitializeComponent(); + + dataGridView1.Columns[COL_Filter].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; + + populateFilters(); + } + + private void populateFilters() + { + var filters = QuickFilters.Filters; + if (!filters.Any()) + return; + + foreach (var filter in filters) + dataGridView1.Rows.Add(filter, "X", filter, "\u25B2", "\u25BC"); + } + + private void SaveBtn_Click(object sender, EventArgs e) + { + var list = dataGridView1.Rows + .OfType() + .Select(r => r.Cells[COL_Filter].Value?.ToString()) + .ToList(); + QuickFilters.ReplaceAll(list); + + _parent.UpdateFilterDropDown(); + this.Close(); + } + + private void CancelBtn_Click(object sender, EventArgs e) => this.Close(); + + private void DataGridView1_CellContentClick(object sender, DataGridViewCellEventArgs e) + { + var dgv = (DataGridView)sender; + + var col = dgv.Columns[e.ColumnIndex]; + if (col is DataGridViewButtonColumn && e.RowIndex >= 0) + { + var row = dgv.Rows[e.RowIndex]; + switch (col.Name) + { + case COL_Delete: + // if final/edit row: do nothing + if (e.RowIndex < dgv.RowCount - 1) + dgv.Rows.Remove(row); + break; + case COL_MoveUp: + // if top: do nothing + if (e.RowIndex < 1) + break; + dgv.Rows.Remove(row); + dgv.Rows.Insert(e.RowIndex - 1, row); + break; + case COL_MoveDown: + // if final/edit row or bottom filter row: do nothing + if (e.RowIndex >= dgv.RowCount - 2) + break; + dgv.Rows.Remove(row); + dgv.Rows.Insert(e.RowIndex + 1, row); + break; + } + } + } + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/EditQuickFilters.resx b/LibationWinForm/UNTESTED/Dialogs/EditQuickFilters.resx new file mode 100644 index 00000000..714d166d --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/EditQuickFilters.resx @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + True + + + True + + + True + + + True + + + True + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Dialogs/EditTagsDialog.Designer.cs b/LibationWinForm/UNTESTED/Dialogs/EditTagsDialog.Designer.cs new file mode 100644 index 00000000..0cf32d3c --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/EditTagsDialog.Designer.cs @@ -0,0 +1,91 @@ +namespace LibationWinForm +{ + partial class EditTagsDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.SaveBtn = new System.Windows.Forms.Button(); + this.newTagsTb = new System.Windows.Forms.TextBox(); + this.label1 = new System.Windows.Forms.Label(); + this.SuspendLayout(); + // + // SaveBtn + // + this.SaveBtn.Location = new System.Drawing.Point(396, 25); + this.SaveBtn.Name = "SaveBtn"; + this.SaveBtn.Size = new System.Drawing.Size(75, 23); + this.SaveBtn.TabIndex = 1; + this.SaveBtn.Text = "Save"; + this.SaveBtn.UseVisualStyleBackColor = true; + this.SaveBtn.Click += new System.EventHandler(this.SaveBtn_Click); + // + // newTagsTb + // + this.newTagsTb.Location = new System.Drawing.Point(12, 27); + this.newTagsTb.Name = "newTagsTb"; + this.newTagsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.newTagsTb.Size = new System.Drawing.Size(375, 20); + this.newTagsTb.TabIndex = 0; + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Location = new System.Drawing.Point(12, 9); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(424, 13); + this.label1.TabIndex = 2; + this.label1.Text = "Tags are separated by a space. Each tag can contain letters, numbers, and undersc" + + "ores"; + // + // EditTagsDialog + // + this.AcceptButton = this.SaveBtn; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(483, 60); + this.Controls.Add(this.label1); + this.Controls.Add(this.newTagsTb); + this.Controls.Add(this.SaveBtn); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog; + this.MaximizeBox = false; + this.MinimizeBox = false; + this.Name = "EditTagsDialog"; + this.ShowIcon = false; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Edit Tags"; + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + private System.Windows.Forms.Button SaveBtn; + private System.Windows.Forms.TextBox newTagsTb; + private System.Windows.Forms.Label label1; + } +} \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Dialogs/EditTagsDialog.cs b/LibationWinForm/UNTESTED/Dialogs/EditTagsDialog.cs new file mode 100644 index 00000000..f23eb683 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/EditTagsDialog.cs @@ -0,0 +1,27 @@ +using System; +using System.Windows.Forms; + +namespace LibationWinForm +{ + public partial class EditTagsDialog : Form + { + public string NewTags { get; private set; } + + public EditTagsDialog() + { + InitializeComponent(); + } + public EditTagsDialog(string title, string rawTags) : this() + { + this.Text = $"Edit Tags - {title}"; + + this.newTagsTb.Text = rawTags; + } + + private void SaveBtn_Click(object sender, EventArgs e) + { + NewTags = this.newTagsTb.Text; + DialogResult = DialogResult.OK; + } + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/EditTagsDialog.resx b/LibationWinForm/UNTESTED/Dialogs/EditTagsDialog.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/EditTagsDialog.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IIndexLibraryDialog.cs b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IIndexLibraryDialog.cs new file mode 100644 index 00000000..96a812a9 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IIndexLibraryDialog.cs @@ -0,0 +1,8 @@ +namespace LibationWinForm +{ + public interface IIndexLibraryDialog : IRunnableDialog + { + int TotalBooksProcessed { get; } + int NewBooksAdded { get; } + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IRunnableDialog.cs b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IRunnableDialog.cs new file mode 100644 index 00000000..066f261f --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IRunnableDialog.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace LibationWinForm +{ + public interface IRunnableDialog : IValidatable + { + IButtonControl AcceptButton { get; set; } + Control.ControlCollection Controls { get; } + Task DoMainWorkAsync(); + string SuccessMessage { get; } + DialogResult ShowDialog(); + DialogResult DialogResult { get; set; } + void Close(); + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IRunnableDialogExt.cs b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IRunnableDialogExt.cs new file mode 100644 index 00000000..514604ed --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IRunnableDialogExt.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using Dinah.Core.Windows.Forms; + +namespace LibationWinForm +{ + public static class IRunnableDialogExt + { + public static DialogResult RunDialog(this IRunnableDialog dialog) + { + // hook up runner before dialog.ShowDialog for all + var acceptButton = (ButtonBase)dialog.AcceptButton; + acceptButton.Click += acceptButton_Click; + + return dialog.ShowDialog(); + } + + // running/workflow logic is in IndexDialogRunner.Run() + private static async void acceptButton_Click(object sender, EventArgs e) + { + var form = ((Control)sender).FindForm(); + var iRunnableDialog = form as IRunnableDialog; + + try + { + await iRunnableDialog.Run(); + } + catch (Exception ex) + { + throw new Exception("Did the database get created correctly? Including seed data. Eg: Update-Database", ex); + } + } + + public static async Task Run(this IRunnableDialog dialog) + { + // validate children + // OfType() -- skips items which aren't of the required type + // Cast() -- throws an exception + var errorStrings = dialog + // get children + .Controls + .GetControlListRecursive() + .OfType() + // and self + .Append(dialog) + // validate. get errors + .Select(c => c.StringBasedValidate()) + // ignore successes + .Where(e => e != null); + if (errorStrings.Any()) + { + MessageBox.Show(errorStrings.Aggregate((a, b) => a + "\r\n" + b)); + return; + } + + // get top level controls only. If Enabled, disable and push on stack + var disabledStack = disable(dialog); + + // lazy-man's async. also violates the intent of async/await. + // use here for now simply for UI responsiveness + await dialog.DoMainWorkAsync().ConfigureAwait(true); + + // after running, unwind and re-enable + enable(disabledStack); + + MessageBox.Show(dialog.SuccessMessage); + + dialog.DialogResult = DialogResult.OK; + dialog.Close(); + } + static Stack disable(IRunnableDialog dialog) + { + var disableStack = new Stack(); + foreach (Control ctrl in dialog.Controls) + { + if (ctrl.Enabled) + { + disableStack.Push(ctrl); + ctrl.Enabled = false; + } + } + return disableStack; + } + static void enable(Stack disabledStack) + { + while (disabledStack.Count > 0) + { + var ctrl = disabledStack.Pop(); + ctrl.Enabled = true; + } + } + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IValidatable.cs b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IValidatable.cs new file mode 100644 index 00000000..8fb2f51f --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/IValidatable.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForm +{ + public interface IValidatable + { + // forms has a framework for ValidateChildren and ErrorProvider.s + // i don't feel like setting it up right now. doing this instead + string StringBasedValidate(); + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/ScanLibraryDialog.Designer.cs b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/ScanLibraryDialog.Designer.cs new file mode 100644 index 00000000..0718ff5e --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/ScanLibraryDialog.Designer.cs @@ -0,0 +1,78 @@ +namespace LibationWinForm +{ + partial class ScanLibraryDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.websiteProcessorControl1 = new LibationWinForm.WebsiteProcessorControl(); + this.BeginScanBtn = new System.Windows.Forms.Button(); + this.SuspendLayout(); + // + // websiteProcessorControl1 + // + this.websiteProcessorControl1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.websiteProcessorControl1.Location = new System.Drawing.Point(12, 12); + this.websiteProcessorControl1.Name = "websiteProcessorControl1"; + this.websiteProcessorControl1.Size = new System.Drawing.Size(324, 137); + this.websiteProcessorControl1.TabIndex = 0; + // + // BeginScanBtn + // + this.BeginScanBtn.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.BeginScanBtn.Location = new System.Drawing.Point(12, 155); + this.BeginScanBtn.Name = "BeginScanBtn"; + this.BeginScanBtn.Size = new System.Drawing.Size(324, 23); + this.BeginScanBtn.TabIndex = 1; + this.BeginScanBtn.Text = "BEGIN SCAN"; + this.BeginScanBtn.UseVisualStyleBackColor = true; + // + // ScanLibraryDialog + // + this.AcceptButton = this.BeginScanBtn; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(348, 190); + this.Controls.Add(this.BeginScanBtn); + this.Controls.Add(this.websiteProcessorControl1); + this.Name = "ScanLibraryDialog"; + this.ShowIcon = false; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Scan Library"; + this.ResumeLayout(false); + + } + + #endregion + + private WebsiteProcessorControl websiteProcessorControl1; + private System.Windows.Forms.Button BeginScanBtn; + } +} \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/ScanLibraryDialog.cs b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/ScanLibraryDialog.cs new file mode 100644 index 00000000..cc2fb6a0 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/ScanLibraryDialog.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Windows.Forms; +using DomainServices; +using Dinah.Core; + +namespace LibationWinForm +{ + public partial class ScanLibraryDialog : Form, IIndexLibraryDialog + { + public ScanLibraryDialog() + { + InitializeComponent(); + } + + public string StringBasedValidate() => null; + + List successMessages = new List(); + public string SuccessMessage => string.Join("\r\n", successMessages); + + public int NewBooksAdded { get; private set; } + public int TotalBooksProcessed { get; private set; } + + public async Task DoMainWorkAsync() + { + List jsonFilepaths; + using (var pageRetriever = websiteProcessorControl1.GetPageRetriever()) + jsonFilepaths = await DownloadLibrary.DownloadLibraryAsync(pageRetriever).ConfigureAwait(false); + + successMessages.Add($"Downloaded {"library page".PluralizeWithCount(jsonFilepaths.Count)}"); + + (TotalBooksProcessed, NewBooksAdded) = await Indexer + .IndexLibraryAsync(jsonFilepaths) + .ConfigureAwait(false); + + successMessages.Add($"Total processed: {TotalBooksProcessed}"); + successMessages.Add($"New: {NewBooksAdded}"); + } + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/ScanLibraryDialog.resx b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/ScanLibraryDialog.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/ScanLibraryDialog.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/WebsiteProcessorControl.Designer.cs b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/WebsiteProcessorControl.Designer.cs new file mode 100644 index 00000000..2641d1c1 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/WebsiteProcessorControl.Designer.cs @@ -0,0 +1,161 @@ +namespace LibationWinForm +{ + partial class WebsiteProcessorControl + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.AuthGb = new System.Windows.Forms.GroupBox(); + this.AuthRb_Browserless = new System.Windows.Forms.RadioButton(); + this.AuthRb_UseCanonicalChrome = new System.Windows.Forms.RadioButton(); + this.label3 = new System.Windows.Forms.Label(); + this.AuthRb_ManualLogin = new System.Windows.Forms.RadioButton(); + this.label2 = new System.Windows.Forms.Label(); + this.PasswordTb = new System.Windows.Forms.TextBox(); + this.UsernameTb = new System.Windows.Forms.TextBox(); + this.AuthGb.SuspendLayout(); + this.SuspendLayout(); + // + // AuthGb + // + this.AuthGb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.AuthGb.Controls.Add(this.AuthRb_Browserless); + this.AuthGb.Controls.Add(this.AuthRb_UseCanonicalChrome); + this.AuthGb.Controls.Add(this.label3); + this.AuthGb.Controls.Add(this.AuthRb_ManualLogin); + this.AuthGb.Controls.Add(this.label2); + this.AuthGb.Controls.Add(this.PasswordTb); + this.AuthGb.Controls.Add(this.UsernameTb); + this.AuthGb.Location = new System.Drawing.Point(0, 0); + this.AuthGb.Name = "AuthGb"; + this.AuthGb.Size = new System.Drawing.Size(324, 137); + this.AuthGb.TabIndex = 1; + this.AuthGb.TabStop = false; + this.AuthGb.Text = "Authentication"; + // + // AuthRb_Browserless + // + this.AuthRb_Browserless.AutoSize = true; + this.AuthRb_Browserless.Checked = true; + this.AuthRb_Browserless.Location = new System.Drawing.Point(6, 19); + this.AuthRb_Browserless.Name = "AuthRb_Browserless"; + this.AuthRb_Browserless.Size = new System.Drawing.Size(143, 17); + this.AuthRb_Browserless.TabIndex = 0; + this.AuthRb_Browserless.TabStop = true; + this.AuthRb_Browserless.Text = "Browserless with cookies"; + this.AuthRb_Browserless.UseVisualStyleBackColor = true; + // + // AuthRb_UseCanonicalChrome + // + this.AuthRb_UseCanonicalChrome.AutoSize = true; + this.AuthRb_UseCanonicalChrome.Location = new System.Drawing.Point(6, 114); + this.AuthRb_UseCanonicalChrome.Name = "AuthRb_UseCanonicalChrome"; + this.AuthRb_UseCanonicalChrome.Size = new System.Drawing.Size(216, 17); + this.AuthRb_UseCanonicalChrome.TabIndex = 6; + this.AuthRb_UseCanonicalChrome.Text = "Use Canonical Chrome. SEE WARNING"; + this.AuthRb_UseCanonicalChrome.UseVisualStyleBackColor = true; + this.AuthRb_UseCanonicalChrome.CheckedChanged += new System.EventHandler(this.AuthRb_UseCanonicalChrome_CheckedChanged); + // + // label3 + // + this.label3.AutoSize = true; + this.label3.Location = new System.Drawing.Point(27, 91); + this.label3.Name = "label3"; + this.label3.Size = new System.Drawing.Size(53, 13); + this.label3.TabIndex = 4; + this.label3.Text = "Password"; + // + // AuthRb_ManualLogin + // + this.AuthRb_ManualLogin.AutoSize = true; + this.AuthRb_ManualLogin.Location = new System.Drawing.Point(6, 42); + this.AuthRb_ManualLogin.Name = "AuthRb_ManualLogin"; + this.AuthRb_ManualLogin.Size = new System.Drawing.Size(89, 17); + this.AuthRb_ManualLogin.TabIndex = 1; + this.AuthRb_ManualLogin.Text = "Manual Login"; + this.AuthRb_ManualLogin.UseVisualStyleBackColor = true; + // + // label2 + // + this.label2.AutoSize = true; + this.label2.Location = new System.Drawing.Point(27, 65); + this.label2.Name = "label2"; + this.label2.Size = new System.Drawing.Size(85, 13); + this.label2.TabIndex = 2; + this.label2.Text = "Username/Email"; + // + // PasswordTb + // + this.PasswordTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.PasswordTb.Location = new System.Drawing.Point(118, 88); + this.PasswordTb.Name = "PasswordTb"; + this.PasswordTb.PasswordChar = '*'; + this.PasswordTb.Size = new System.Drawing.Size(200, 20); + this.PasswordTb.TabIndex = 5; + this.PasswordTb.TextChanged += new System.EventHandler(this.UserIsEnteringLoginInfo); + this.PasswordTb.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.UsernamePasswordTb_KeyPress); + this.PasswordTb.MouseUp += new System.Windows.Forms.MouseEventHandler(this.UserIsEnteringLoginInfo); + // + // UsernameTb + // + this.UsernameTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.UsernameTb.Location = new System.Drawing.Point(118, 62); + this.UsernameTb.Name = "UsernameTb"; + this.UsernameTb.Size = new System.Drawing.Size(200, 20); + this.UsernameTb.TabIndex = 3; + this.UsernameTb.TextChanged += new System.EventHandler(this.UserIsEnteringLoginInfo); + this.UsernameTb.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.UsernamePasswordTb_KeyPress); + this.UsernameTb.MouseUp += new System.Windows.Forms.MouseEventHandler(this.UserIsEnteringLoginInfo); + // + // WebsiteProcessorControl + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.Controls.Add(this.AuthGb); + this.Name = "WebsiteProcessorControl"; + this.Size = new System.Drawing.Size(324, 137); + this.AuthGb.ResumeLayout(false); + this.AuthGb.PerformLayout(); + this.ResumeLayout(false); + + } + + #endregion + + private System.Windows.Forms.GroupBox AuthGb; + private System.Windows.Forms.RadioButton AuthRb_UseCanonicalChrome; + private System.Windows.Forms.Label label3; + private System.Windows.Forms.RadioButton AuthRb_ManualLogin; + private System.Windows.Forms.Label label2; + private System.Windows.Forms.TextBox PasswordTb; + private System.Windows.Forms.TextBox UsernameTb; + private System.Windows.Forms.RadioButton AuthRb_Browserless; + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/WebsiteProcessorControl.cs b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/WebsiteProcessorControl.cs new file mode 100644 index 00000000..493826d0 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/WebsiteProcessorControl.cs @@ -0,0 +1,47 @@ +using System; +using System.Windows.Forms; +using AudibleDotComAutomation; + +namespace LibationWinForm +{ + public partial class WebsiteProcessorControl : UserControl, IValidatable + { + public event EventHandler KeyPressSubmit; + + public WebsiteProcessorControl() + { + InitializeComponent(); + } + + public IPageRetriever GetPageRetriever() + => AuthRb_UseCanonicalChrome.Checked ? new UserDataSeleniumRetriever() + : AuthRb_Browserless.Checked ? (IPageRetriever)new BrowserlessRetriever() + : new ManualLoginSeleniumRetriever(UsernameTb.Text, PasswordTb.Text); + + public string StringBasedValidate() + { + if (AuthRb_ManualLogin.Checked && (string.IsNullOrWhiteSpace(UsernameTb.Text) || string.IsNullOrWhiteSpace(PasswordTb.Text))) + return "must fill in username and password"; + + return null; + } + + private void UsernamePasswordTb_KeyPress(object sender, KeyPressEventArgs e) + { + if (e.KeyChar == (char)Keys.Return) + { + KeyPressSubmit?.Invoke(sender, e); + // call your method for action on enter + e.Handled = true; // suppress default handling + } + } + + private void UserIsEnteringLoginInfo(object sender, EventArgs e) => AuthRb_ManualLogin.Checked = true; + + private void AuthRb_UseCanonicalChrome_CheckedChanged(object sender, EventArgs e) + { + if (AuthRb_UseCanonicalChrome.Checked) + MessageBox.Show(@"A canonical version of Chrome will be used including User Data, cookies. etc. Selenium chromedriver won't launch URL if another Chrome instance is open"); + } + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/WebsiteProcessorControl.resx b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/WebsiteProcessorControl.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/IndexDialogs/WebsiteProcessorControl.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Dialogs/SearchSyntaxDialog.Designer.cs b/LibationWinForm/UNTESTED/Dialogs/SearchSyntaxDialog.Designer.cs new file mode 100644 index 00000000..0348a8a8 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/SearchSyntaxDialog.Designer.cs @@ -0,0 +1,133 @@ +namespace LibationWinForm.Dialogs +{ + partial class SearchSyntaxDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.label1 = new System.Windows.Forms.Label(); + this.label2 = new System.Windows.Forms.Label(); + this.label3 = new System.Windows.Forms.Label(); + this.label4 = new System.Windows.Forms.Label(); + this.label5 = new System.Windows.Forms.Label(); + this.closeBtn = new System.Windows.Forms.Button(); + this.SuspendLayout(); + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Location = new System.Drawing.Point(12, 9); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(358, 52); + this.label1.TabIndex = 0; + this.label1.Text = "Full Lucene query syntax is supported\r\nFields with similar names are synomyns (eg" + + ": Author, Authors, AuthorNames)\r\n\r\nTAG FORMAT: [tagName]"; + // + // label2 + // + this.label2.AutoSize = true; + this.label2.Location = new System.Drawing.Point(12, 71); + this.label2.Name = "label2"; + this.label2.Size = new System.Drawing.Size(118, 65); + this.label2.TabIndex = 1; + this.label2.Text = "STRING FIELDS\r\n\r\nSearch for wizard of oz:\r\n title:oz\r\n title:\"wizard of o" + + "z\""; + // + // label3 + // + this.label3.AutoSize = true; + this.label3.Location = new System.Drawing.Point(233, 71); + this.label3.Name = "label3"; + this.label3.Size = new System.Drawing.Size(195, 78); + this.label3.TabIndex = 2; + this.label3.Text = "NUMBER FIELDS\r\n\r\nFind books between 1-100 minutes long\r\n length:[1 TO 100]\r\nF" + + "ind books exactly 1 hr long\r\n length:60"; + // + // label4 + // + this.label4.AutoSize = true; + this.label4.Location = new System.Drawing.Point(454, 71); + this.label4.Name = "label4"; + this.label4.Size = new System.Drawing.Size(168, 52); + this.label4.TabIndex = 3; + this.label4.Text = "BOOL FIELDS\r\n\r\nFind books that you haven\'t rated:\r\n -IsRated"; + // + // label5 + // + this.label5.AutoSize = true; + this.label5.Location = new System.Drawing.Point(673, 71); + this.label5.Name = "label5"; + this.label5.Size = new System.Drawing.Size(257, 78); + this.label5.TabIndex = 4; + this.label5.Text = "ID FIELDS\r\n\r\nAlice\'s Adventures in Wonderland (ID: B015D78L0U)\r\n id:B015D78L0" + + "U\r\n\r\nAll of these are synonyms for the ID field"; + // + // closeBtn + // + this.closeBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.closeBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.closeBtn.Location = new System.Drawing.Point(890, 415); + this.closeBtn.Name = "closeBtn"; + this.closeBtn.Size = new System.Drawing.Size(75, 23); + this.closeBtn.TabIndex = 5; + this.closeBtn.Text = "Close"; + this.closeBtn.UseVisualStyleBackColor = true; + this.closeBtn.Click += new System.EventHandler(this.CloseBtn_Click); + // + // SearchSyntaxDialog + // + this.AcceptButton = this.closeBtn; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.CancelButton = this.closeBtn; + this.ClientSize = new System.Drawing.Size(977, 450); + this.Controls.Add(this.closeBtn); + this.Controls.Add(this.label5); + this.Controls.Add(this.label4); + this.Controls.Add(this.label3); + this.Controls.Add(this.label2); + this.Controls.Add(this.label1); + this.MaximizeBox = false; + this.MinimizeBox = false; + this.Name = "SearchSyntaxDialog"; + this.ShowIcon = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Filter options"; + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Label label1; + private System.Windows.Forms.Label label2; + private System.Windows.Forms.Label label3; + private System.Windows.Forms.Label label4; + private System.Windows.Forms.Label label5; + private System.Windows.Forms.Button closeBtn; + } +} \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Dialogs/SearchSyntaxDialog.cs b/LibationWinForm/UNTESTED/Dialogs/SearchSyntaxDialog.cs new file mode 100644 index 00000000..f1791f7b --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/SearchSyntaxDialog.cs @@ -0,0 +1,20 @@ +using System; +using System.Windows.Forms; + +namespace LibationWinForm.Dialogs +{ + public partial class SearchSyntaxDialog : Form + { + public SearchSyntaxDialog() + { + InitializeComponent(); + + label2.Text += "\r\n\r\n" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchStringFields()); + label3.Text += "\r\n\r\n" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchNumberFields()); + label4.Text += "\r\n\r\n" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchBoolFields()); + label5.Text += "\r\n\r\n" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchIdFields()); + } + + private void CloseBtn_Click(object sender, EventArgs e) => this.Close(); + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/SearchSyntaxDialog.resx b/LibationWinForm/UNTESTED/Dialogs/SearchSyntaxDialog.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/SearchSyntaxDialog.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Dialogs/SettingsDialog.Designer.cs b/LibationWinForm/UNTESTED/Dialogs/SettingsDialog.Designer.cs new file mode 100644 index 00000000..089b90d0 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/SettingsDialog.Designer.cs @@ -0,0 +1,411 @@ +namespace LibationWinForm +{ + partial class SettingsDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.settingsFileLbl = new System.Windows.Forms.Label(); + this.settingsFileTb = new System.Windows.Forms.TextBox(); + this.decryptKeyLbl = new System.Windows.Forms.Label(); + this.decryptKeyTb = new System.Windows.Forms.TextBox(); + this.booksLocationLbl = new System.Windows.Forms.Label(); + this.booksLocationTb = new System.Windows.Forms.TextBox(); + this.booksLocationSearchBtn = new System.Windows.Forms.Button(); + this.settingsFileDescLbl = new System.Windows.Forms.Label(); + this.decryptKeyDescLbl = new System.Windows.Forms.Label(); + this.booksLocationDescLbl = new System.Windows.Forms.Label(); + this.libationFilesGb = new System.Windows.Forms.GroupBox(); + this.libationFilesDescLbl = new System.Windows.Forms.Label(); + this.libationFilesCustomBtn = new System.Windows.Forms.Button(); + this.libationFilesCustomTb = new System.Windows.Forms.TextBox(); + this.libationFilesCustomRb = new System.Windows.Forms.RadioButton(); + this.libationFilesMyDocsRb = new System.Windows.Forms.RadioButton(); + this.libationFilesRootRb = new System.Windows.Forms.RadioButton(); + this.downloadsInProgressGb = new System.Windows.Forms.GroupBox(); + this.downloadsInProgressLibationFilesRb = new System.Windows.Forms.RadioButton(); + this.downloadsInProgressWinTempRb = new System.Windows.Forms.RadioButton(); + this.downloadsInProgressDescLbl = new System.Windows.Forms.Label(); + this.decryptInProgressGb = new System.Windows.Forms.GroupBox(); + this.decryptInProgressLibationFilesRb = new System.Windows.Forms.RadioButton(); + this.decryptInProgressWinTempRb = new System.Windows.Forms.RadioButton(); + this.decryptInProgressDescLbl = new System.Windows.Forms.Label(); + this.saveBtn = new System.Windows.Forms.Button(); + this.cancelBtn = new System.Windows.Forms.Button(); + this.libationFilesGb.SuspendLayout(); + this.downloadsInProgressGb.SuspendLayout(); + this.decryptInProgressGb.SuspendLayout(); + this.SuspendLayout(); + // + // settingsFileLbl + // + this.settingsFileLbl.AutoSize = true; + this.settingsFileLbl.Location = new System.Drawing.Point(7, 15); + this.settingsFileLbl.Name = "settingsFileLbl"; + this.settingsFileLbl.Size = new System.Drawing.Size(61, 13); + this.settingsFileLbl.TabIndex = 0; + this.settingsFileLbl.Text = "Settings file"; + // + // settingsFileTb + // + this.settingsFileTb.Location = new System.Drawing.Point(90, 12); + this.settingsFileTb.Name = "settingsFileTb"; + this.settingsFileTb.ReadOnly = true; + this.settingsFileTb.Size = new System.Drawing.Size(698, 20); + this.settingsFileTb.TabIndex = 1; + // + // decryptKeyLbl + // + this.decryptKeyLbl.AutoSize = true; + this.decryptKeyLbl.Location = new System.Drawing.Point(7, 59); + this.decryptKeyLbl.Name = "decryptKeyLbl"; + this.decryptKeyLbl.Size = new System.Drawing.Size(64, 13); + this.decryptKeyLbl.TabIndex = 3; + this.decryptKeyLbl.Text = "Decrypt key"; + // + // decryptKeyTb + // + this.decryptKeyTb.Location = new System.Drawing.Point(90, 56); + this.decryptKeyTb.Name = "decryptKeyTb"; + this.decryptKeyTb.Size = new System.Drawing.Size(100, 20); + this.decryptKeyTb.TabIndex = 4; + // + // booksLocationLbl + // + this.booksLocationLbl.AutoSize = true; + this.booksLocationLbl.Location = new System.Drawing.Point(7, 103); + this.booksLocationLbl.Name = "booksLocationLbl"; + this.booksLocationLbl.Size = new System.Drawing.Size(77, 13); + this.booksLocationLbl.TabIndex = 6; + this.booksLocationLbl.Text = "Books location"; + // + // booksLocationTb + // + this.booksLocationTb.Location = new System.Drawing.Point(90, 100); + this.booksLocationTb.Name = "booksLocationTb"; + this.booksLocationTb.Size = new System.Drawing.Size(657, 20); + this.booksLocationTb.TabIndex = 7; + // + // booksLocationSearchBtn + // + this.booksLocationSearchBtn.Location = new System.Drawing.Point(753, 98); + this.booksLocationSearchBtn.Name = "booksLocationSearchBtn"; + this.booksLocationSearchBtn.Size = new System.Drawing.Size(35, 23); + this.booksLocationSearchBtn.TabIndex = 8; + this.booksLocationSearchBtn.Text = "..."; + this.booksLocationSearchBtn.UseVisualStyleBackColor = true; + this.booksLocationSearchBtn.Click += new System.EventHandler(this.booksLocationSearchBtn_Click); + // + // settingsFileDescLbl + // + this.settingsFileDescLbl.AutoSize = true; + this.settingsFileDescLbl.Location = new System.Drawing.Point(87, 35); + this.settingsFileDescLbl.Name = "settingsFileDescLbl"; + this.settingsFileDescLbl.Size = new System.Drawing.Size(36, 13); + this.settingsFileDescLbl.TabIndex = 2; + this.settingsFileDescLbl.Text = "[desc]"; + // + // decryptKeyDescLbl + // + this.decryptKeyDescLbl.AutoSize = true; + this.decryptKeyDescLbl.Location = new System.Drawing.Point(87, 79); + this.decryptKeyDescLbl.Name = "decryptKeyDescLbl"; + this.decryptKeyDescLbl.Size = new System.Drawing.Size(36, 13); + this.decryptKeyDescLbl.TabIndex = 5; + this.decryptKeyDescLbl.Text = "[desc]"; + // + // booksLocationDescLbl + // + this.booksLocationDescLbl.AutoSize = true; + this.booksLocationDescLbl.Location = new System.Drawing.Point(87, 123); + this.booksLocationDescLbl.Name = "booksLocationDescLbl"; + this.booksLocationDescLbl.Size = new System.Drawing.Size(36, 13); + this.booksLocationDescLbl.TabIndex = 9; + this.booksLocationDescLbl.Text = "[desc]"; + // + // libationFilesGb + // + this.libationFilesGb.Controls.Add(this.libationFilesDescLbl); + this.libationFilesGb.Controls.Add(this.libationFilesCustomBtn); + this.libationFilesGb.Controls.Add(this.libationFilesCustomTb); + this.libationFilesGb.Controls.Add(this.libationFilesCustomRb); + this.libationFilesGb.Controls.Add(this.libationFilesMyDocsRb); + this.libationFilesGb.Controls.Add(this.libationFilesRootRb); + this.libationFilesGb.Location = new System.Drawing.Point(12, 139); + this.libationFilesGb.Name = "libationFilesGb"; + this.libationFilesGb.Size = new System.Drawing.Size(776, 131); + this.libationFilesGb.TabIndex = 10; + this.libationFilesGb.TabStop = false; + this.libationFilesGb.Text = "Libation files"; + // + // libationFilesDescLbl + // + this.libationFilesDescLbl.AutoSize = true; + this.libationFilesDescLbl.Location = new System.Drawing.Point(6, 16); + this.libationFilesDescLbl.Name = "libationFilesDescLbl"; + this.libationFilesDescLbl.Size = new System.Drawing.Size(36, 13); + this.libationFilesDescLbl.TabIndex = 0; + this.libationFilesDescLbl.Text = "[desc]"; + // + // libationFilesCustomBtn + // + this.libationFilesCustomBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right))); + this.libationFilesCustomBtn.Location = new System.Drawing.Point(741, 102); + this.libationFilesCustomBtn.Name = "libationFilesCustomBtn"; + this.libationFilesCustomBtn.Size = new System.Drawing.Size(35, 23); + this.libationFilesCustomBtn.TabIndex = 5; + this.libationFilesCustomBtn.Text = "..."; + this.libationFilesCustomBtn.UseVisualStyleBackColor = true; + this.libationFilesCustomBtn.Click += new System.EventHandler(this.libationFilesCustomBtn_Click); + // + // libationFilesCustomTb + // + this.libationFilesCustomTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.libationFilesCustomTb.Location = new System.Drawing.Point(29, 104); + this.libationFilesCustomTb.Name = "libationFilesCustomTb"; + this.libationFilesCustomTb.Size = new System.Drawing.Size(706, 20); + this.libationFilesCustomTb.TabIndex = 4; + this.libationFilesCustomTb.TextChanged += new System.EventHandler(this.libationFiles_Changed); + // + // libationFilesCustomRb + // + this.libationFilesCustomRb.AutoSize = true; + this.libationFilesCustomRb.Location = new System.Drawing.Point(9, 107); + this.libationFilesCustomRb.Name = "libationFilesCustomRb"; + this.libationFilesCustomRb.Size = new System.Drawing.Size(14, 13); + this.libationFilesCustomRb.TabIndex = 3; + this.libationFilesCustomRb.TabStop = true; + this.libationFilesCustomRb.UseVisualStyleBackColor = true; + // + // libationFilesMyDocsRb + // + this.libationFilesMyDocsRb.AutoSize = true; + this.libationFilesMyDocsRb.CheckAlign = System.Drawing.ContentAlignment.TopLeft; + this.libationFilesMyDocsRb.Location = new System.Drawing.Point(9, 68); + this.libationFilesMyDocsRb.Name = "libationFilesMyDocsRb"; + this.libationFilesMyDocsRb.Size = new System.Drawing.Size(111, 30); + this.libationFilesMyDocsRb.TabIndex = 2; + this.libationFilesMyDocsRb.TabStop = true; + this.libationFilesMyDocsRb.Text = "[desc]\r\n[myDocs\\Libation]"; + this.libationFilesMyDocsRb.UseVisualStyleBackColor = true; + this.libationFilesMyDocsRb.CheckedChanged += new System.EventHandler(this.libationFiles_Changed); + // + // libationFilesRootRb + // + this.libationFilesRootRb.AutoSize = true; + this.libationFilesRootRb.CheckAlign = System.Drawing.ContentAlignment.TopLeft; + this.libationFilesRootRb.Location = new System.Drawing.Point(9, 32); + this.libationFilesRootRb.Name = "libationFilesRootRb"; + this.libationFilesRootRb.Size = new System.Drawing.Size(113, 30); + this.libationFilesRootRb.TabIndex = 1; + this.libationFilesRootRb.TabStop = true; + this.libationFilesRootRb.Text = "[desc]\r\n[exeRoot\\Libation]"; + this.libationFilesRootRb.UseVisualStyleBackColor = true; + this.libationFilesRootRb.CheckedChanged += new System.EventHandler(this.libationFiles_Changed); + // + // downloadsInProgressGb + // + this.downloadsInProgressGb.Controls.Add(this.downloadsInProgressLibationFilesRb); + this.downloadsInProgressGb.Controls.Add(this.downloadsInProgressWinTempRb); + this.downloadsInProgressGb.Controls.Add(this.downloadsInProgressDescLbl); + this.downloadsInProgressGb.Location = new System.Drawing.Point(12, 276); + this.downloadsInProgressGb.Name = "downloadsInProgressGb"; + this.downloadsInProgressGb.Size = new System.Drawing.Size(776, 117); + this.downloadsInProgressGb.TabIndex = 11; + this.downloadsInProgressGb.TabStop = false; + this.downloadsInProgressGb.Text = "Downloads in progress"; + // + // downloadsInProgressLibationFilesRb + // + this.downloadsInProgressLibationFilesRb.AutoSize = true; + this.downloadsInProgressLibationFilesRb.CheckAlign = System.Drawing.ContentAlignment.TopLeft; + this.downloadsInProgressLibationFilesRb.Location = new System.Drawing.Point(9, 81); + this.downloadsInProgressLibationFilesRb.Name = "downloadsInProgressLibationFilesRb"; + this.downloadsInProgressLibationFilesRb.Size = new System.Drawing.Size(193, 30); + this.downloadsInProgressLibationFilesRb.TabIndex = 2; + this.downloadsInProgressLibationFilesRb.TabStop = true; + this.downloadsInProgressLibationFilesRb.Text = "[desc]\r\n[libationFiles\\DownloadsInProgress]"; + this.downloadsInProgressLibationFilesRb.UseVisualStyleBackColor = true; + // + // downloadsInProgressWinTempRb + // + this.downloadsInProgressWinTempRb.AutoSize = true; + this.downloadsInProgressWinTempRb.CheckAlign = System.Drawing.ContentAlignment.TopLeft; + this.downloadsInProgressWinTempRb.Location = new System.Drawing.Point(9, 45); + this.downloadsInProgressWinTempRb.Name = "downloadsInProgressWinTempRb"; + this.downloadsInProgressWinTempRb.Size = new System.Drawing.Size(182, 30); + this.downloadsInProgressWinTempRb.TabIndex = 1; + this.downloadsInProgressWinTempRb.TabStop = true; + this.downloadsInProgressWinTempRb.Text = "[desc]\r\n[winTemp\\DownloadsInProgress]"; + this.downloadsInProgressWinTempRb.UseVisualStyleBackColor = true; + // + // downloadsInProgressDescLbl + // + this.downloadsInProgressDescLbl.AutoSize = true; + this.downloadsInProgressDescLbl.Location = new System.Drawing.Point(6, 16); + this.downloadsInProgressDescLbl.Name = "downloadsInProgressDescLbl"; + this.downloadsInProgressDescLbl.Size = new System.Drawing.Size(38, 26); + this.downloadsInProgressDescLbl.TabIndex = 0; + this.downloadsInProgressDescLbl.Text = "[desc]\r\n[line 2]"; + // + // decryptInProgressGb + // + this.decryptInProgressGb.Controls.Add(this.decryptInProgressLibationFilesRb); + this.decryptInProgressGb.Controls.Add(this.decryptInProgressWinTempRb); + this.decryptInProgressGb.Controls.Add(this.decryptInProgressDescLbl); + this.decryptInProgressGb.Location = new System.Drawing.Point(12, 399); + this.decryptInProgressGb.Name = "decryptInProgressGb"; + this.decryptInProgressGb.Size = new System.Drawing.Size(776, 117); + this.decryptInProgressGb.TabIndex = 12; + this.decryptInProgressGb.TabStop = false; + this.decryptInProgressGb.Text = "Decrypt in progress"; + // + // decryptInProgressLibationFilesRb + // + this.decryptInProgressLibationFilesRb.AutoSize = true; + this.decryptInProgressLibationFilesRb.CheckAlign = System.Drawing.ContentAlignment.TopLeft; + this.decryptInProgressLibationFilesRb.Location = new System.Drawing.Point(6, 81); + this.decryptInProgressLibationFilesRb.Name = "decryptInProgressLibationFilesRb"; + this.decryptInProgressLibationFilesRb.Size = new System.Drawing.Size(177, 30); + this.decryptInProgressLibationFilesRb.TabIndex = 3; + this.decryptInProgressLibationFilesRb.TabStop = true; + this.decryptInProgressLibationFilesRb.Text = "[desc]\r\n[libationFiles\\DecryptInProgress]"; + this.decryptInProgressLibationFilesRb.UseVisualStyleBackColor = true; + // + // decryptInProgressWinTempRb + // + this.decryptInProgressWinTempRb.AutoSize = true; + this.decryptInProgressWinTempRb.CheckAlign = System.Drawing.ContentAlignment.TopLeft; + this.decryptInProgressWinTempRb.Location = new System.Drawing.Point(6, 45); + this.decryptInProgressWinTempRb.Name = "decryptInProgressWinTempRb"; + this.decryptInProgressWinTempRb.Size = new System.Drawing.Size(166, 30); + this.decryptInProgressWinTempRb.TabIndex = 2; + this.decryptInProgressWinTempRb.TabStop = true; + this.decryptInProgressWinTempRb.Text = "[desc]\r\n[winTemp\\DecryptInProgress]"; + this.decryptInProgressWinTempRb.UseVisualStyleBackColor = true; + // + // decryptInProgressDescLbl + // + this.decryptInProgressDescLbl.AutoSize = true; + this.decryptInProgressDescLbl.Location = new System.Drawing.Point(6, 16); + this.decryptInProgressDescLbl.Name = "decryptInProgressDescLbl"; + this.decryptInProgressDescLbl.Size = new System.Drawing.Size(38, 26); + this.decryptInProgressDescLbl.TabIndex = 1; + this.decryptInProgressDescLbl.Text = "[desc]\r\n[line 2]"; + // + // saveBtn + // + this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.saveBtn.Location = new System.Drawing.Point(612, 522); + this.saveBtn.Name = "saveBtn"; + this.saveBtn.Size = new System.Drawing.Size(75, 23); + this.saveBtn.TabIndex = 13; + this.saveBtn.Text = "Save"; + this.saveBtn.UseVisualStyleBackColor = true; + this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click); + // + // cancelBtn + // + this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); + this.cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel; + this.cancelBtn.Location = new System.Drawing.Point(713, 522); + this.cancelBtn.Name = "cancelBtn"; + this.cancelBtn.Size = new System.Drawing.Size(75, 23); + this.cancelBtn.TabIndex = 14; + this.cancelBtn.Text = "Cancel"; + this.cancelBtn.UseVisualStyleBackColor = true; + this.cancelBtn.Click += new System.EventHandler(this.cancelBtn_Click); + // + // SettingsDialog + // + this.AcceptButton = this.saveBtn; + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.CancelButton = this.cancelBtn; + this.ClientSize = new System.Drawing.Size(800, 557); + this.Controls.Add(this.cancelBtn); + this.Controls.Add(this.saveBtn); + this.Controls.Add(this.decryptInProgressGb); + this.Controls.Add(this.downloadsInProgressGb); + this.Controls.Add(this.libationFilesGb); + this.Controls.Add(this.booksLocationDescLbl); + this.Controls.Add(this.decryptKeyDescLbl); + this.Controls.Add(this.settingsFileDescLbl); + this.Controls.Add(this.booksLocationSearchBtn); + this.Controls.Add(this.booksLocationTb); + this.Controls.Add(this.booksLocationLbl); + this.Controls.Add(this.decryptKeyTb); + this.Controls.Add(this.decryptKeyLbl); + this.Controls.Add(this.settingsFileTb); + this.Controls.Add(this.settingsFileLbl); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow; + this.Name = "SettingsDialog"; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + this.Text = "Edit Settings"; + this.Load += new System.EventHandler(this.SettingsDialog_Load); + this.libationFilesGb.ResumeLayout(false); + this.libationFilesGb.PerformLayout(); + this.downloadsInProgressGb.ResumeLayout(false); + this.downloadsInProgressGb.PerformLayout(); + this.decryptInProgressGb.ResumeLayout(false); + this.decryptInProgressGb.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Label settingsFileLbl; + private System.Windows.Forms.TextBox settingsFileTb; + private System.Windows.Forms.Label decryptKeyLbl; + private System.Windows.Forms.TextBox decryptKeyTb; + private System.Windows.Forms.Label booksLocationLbl; + private System.Windows.Forms.TextBox booksLocationTb; + private System.Windows.Forms.Button booksLocationSearchBtn; + private System.Windows.Forms.Label settingsFileDescLbl; + private System.Windows.Forms.Label decryptKeyDescLbl; + private System.Windows.Forms.Label booksLocationDescLbl; + private System.Windows.Forms.GroupBox libationFilesGb; + private System.Windows.Forms.Button libationFilesCustomBtn; + private System.Windows.Forms.TextBox libationFilesCustomTb; + private System.Windows.Forms.RadioButton libationFilesCustomRb; + private System.Windows.Forms.RadioButton libationFilesMyDocsRb; + private System.Windows.Forms.RadioButton libationFilesRootRb; + private System.Windows.Forms.Label libationFilesDescLbl; + private System.Windows.Forms.GroupBox downloadsInProgressGb; + private System.Windows.Forms.Label downloadsInProgressDescLbl; + private System.Windows.Forms.RadioButton downloadsInProgressWinTempRb; + private System.Windows.Forms.RadioButton downloadsInProgressLibationFilesRb; + private System.Windows.Forms.GroupBox decryptInProgressGb; + private System.Windows.Forms.Label decryptInProgressDescLbl; + private System.Windows.Forms.RadioButton decryptInProgressLibationFilesRb; + private System.Windows.Forms.RadioButton decryptInProgressWinTempRb; + private System.Windows.Forms.Button saveBtn; + private System.Windows.Forms.Button cancelBtn; + } +} \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Dialogs/SettingsDialog.cs b/LibationWinForm/UNTESTED/Dialogs/SettingsDialog.cs new file mode 100644 index 00000000..46858f3c --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/SettingsDialog.cs @@ -0,0 +1,163 @@ +using FileManager; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using Dinah.Core; + +namespace LibationWinForm +{ + public partial class SettingsDialog : Form + { + Configuration config { get; } = Configuration.Instance; + Func desc { get; } = Configuration.GetDescription; + string exeRoot { get; } + string myDocs { get; } + + public SettingsDialog() + { + InitializeComponent(); + + exeRoot = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Exe.FileLocationOnDisk), "Libation")); + myDocs = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation")); + } + + private void SettingsDialog_Load(object sender, EventArgs e) + { + this.settingsFileTb.Text = config.Filepath; + this.settingsFileDescLbl.Text = desc(nameof(config.Filepath)); + + this.decryptKeyTb.Text = config.DecryptKey; + this.decryptKeyDescLbl.Text = desc(nameof(config.DecryptKey)); + + this.booksLocationTb.Text = config.Books; + this.booksLocationDescLbl.Text = desc(nameof(config.Books)); + + libationFilesDescLbl.Text = desc(nameof(config.LibationFiles)); + this.libationFilesRootRb.Text = "In the same folder that Libation is running from\r\n" + exeRoot; + this.libationFilesMyDocsRb.Text = "In My Documents\r\n" + myDocs; + if (config.LibationFiles == exeRoot) + libationFilesRootRb.Checked = true; + else if (config.LibationFiles == myDocs) + libationFilesMyDocsRb.Checked = true; + else + { + libationFilesCustomRb.Checked = true; + libationFilesCustomTb.Text = config.LibationFiles; + } + + this.downloadsInProgressDescLbl.Text = desc(nameof(config.DownloadsInProgressEnum)); + var winTempDownloadsInProgress = Path.Combine(config.WinTemp, "DownloadsInProgress"); + this.downloadsInProgressWinTempRb.Text = "In your Windows temporary folder\r\n" + winTempDownloadsInProgress; + switch (config.DownloadsInProgressEnum) + { + case "LibationFiles": + downloadsInProgressLibationFilesRb.Checked = true; + break; + case "WinTemp": + default: + downloadsInProgressWinTempRb.Checked = true; + break; + } + + this.decryptInProgressDescLbl.Text = desc(nameof(config.DecryptInProgressEnum)); + var winTempDecryptInProgress = Path.Combine(config.WinTemp, "DecryptInProgress"); + this.decryptInProgressWinTempRb.Text = "In your Windows temporary folder\r\n" + winTempDecryptInProgress; + switch (config.DecryptInProgressEnum) + { + case "LibationFiles": + decryptInProgressLibationFilesRb.Checked = true; + break; + case "WinTemp": + default: + decryptInProgressWinTempRb.Checked = true; + break; + } + + libationFiles_Changed(this, null); + } + + private void libationFiles_Changed(object sender, EventArgs e) + { + var libationFilesDir + = libationFilesRootRb.Checked ? exeRoot + : libationFilesMyDocsRb.Checked ? myDocs + : libationFilesCustomTb.Text; + + var downloadsInProgress = Path.Combine(libationFilesDir, "DownloadsInProgress"); + this.downloadsInProgressLibationFilesRb.Text = $"In your Libation Files (ie: program-created files)\r\n{downloadsInProgress}"; + + var decryptInProgress = Path.Combine(libationFilesDir, "DecryptInProgress"); + this.decryptInProgressLibationFilesRb.Text = $"In your Libation Files (ie: program-created files)\r\n{decryptInProgress}"; + } + + private void booksLocationSearchBtn_Click(object sender, EventArgs e) => selectFolder("Search for books location", this.booksLocationTb); + + private void libationFilesCustomBtn_Click(object sender, EventArgs e) => selectFolder("Search for Libation Files location", this.libationFilesCustomTb); + + private static void selectFolder(string desc, TextBox textbox) + { + var dialog = new FolderBrowserDialog { Description = desc, SelectedPath = "" }; + dialog.ShowDialog(); + if (!string.IsNullOrWhiteSpace(dialog.SelectedPath)) + textbox.Text = dialog.SelectedPath; + } + + private void saveBtn_Click(object sender, EventArgs e) + { + config.DecryptKey = this.decryptKeyTb.Text; + + var pathsChanged = false; + + if (!Directory.Exists(this.booksLocationTb.Text)) + MessageBox.Show("Not saving change to Books location. This folder does not exist:\r\n" + this.booksLocationTb.Text); + else if (config.Books != this.booksLocationTb.Text) + { + pathsChanged = true; + config.Books = this.booksLocationTb.Text; + } + + var libationDir + = libationFilesRootRb.Checked ? exeRoot + : libationFilesMyDocsRb.Checked ? myDocs + : libationFilesCustomTb.Text; + if (!Directory.Exists(libationDir)) + MessageBox.Show("Not saving change to Libation Files location. This folder does not exist:\r\n" + libationDir); + else if (config.LibationFiles != libationDir) + { + pathsChanged = true; + config.LibationFiles = libationDir; + } + + config.DownloadsInProgressEnum = downloadsInProgressLibationFilesRb.Checked ? "LibationFiles" : "WinTemp"; + config.DecryptInProgressEnum = decryptInProgressLibationFilesRb.Checked ? "LibationFiles" : "WinTemp"; + + if (pathsChanged) + { + var shutdownResult = MessageBox.Show( + "You have changed a file path important for this program. All files will remain in their original location; nothing will be moved. It is highly recommended that you restart this program so these changes are handled correctly." + + "\r\n" + + "\r\nClose program?", + "Restart program", + MessageBoxButtons.YesNo, + MessageBoxIcon.Exclamation, + MessageBoxDefaultButton.Button1); + if (shutdownResult == DialogResult.Yes) + { + Application.Exit(); + } + } + + this.DialogResult = DialogResult.OK; + this.Close(); + } + + private void cancelBtn_Click(object sender, EventArgs e) => this.Close(); + } +} diff --git a/LibationWinForm/UNTESTED/Dialogs/SettingsDialog.resx b/LibationWinForm/UNTESTED/Dialogs/SettingsDialog.resx new file mode 100644 index 00000000..1af7de15 --- /dev/null +++ b/LibationWinForm/UNTESTED/Dialogs/SettingsDialog.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Form1.Designer.cs b/LibationWinForm/UNTESTED/Form1.Designer.cs new file mode 100644 index 00000000..59f30977 --- /dev/null +++ b/LibationWinForm/UNTESTED/Form1.Designer.cs @@ -0,0 +1,303 @@ +namespace LibationWinForm +{ + partial class Form1 + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1)); + this.gridPanel = new System.Windows.Forms.Panel(); + this.filterHelpBtn = new System.Windows.Forms.Button(); + this.filterBtn = new System.Windows.Forms.Button(); + this.filterSearchTb = new System.Windows.Forms.TextBox(); + this.menuStrip1 = new System.Windows.Forms.MenuStrip(); + this.indexToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.scanLibraryToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.reimportMostRecentLibraryScanToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.beginImportingBookDetailsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.liberateToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.beginBookBackupsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.beginPdfBackupsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.quickFiltersToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.firstFilterIsDefaultToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.editQuickFiltersToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator(); + this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.statusStrip1 = new System.Windows.Forms.StatusStrip(); + this.visibleCountLbl = new System.Windows.Forms.ToolStripStatusLabel(); + this.springLbl = new System.Windows.Forms.ToolStripStatusLabel(); + this.backupsCountsLbl = new System.Windows.Forms.ToolStripStatusLabel(); + this.pdfsCountsLbl = new System.Windows.Forms.ToolStripStatusLabel(); + this.addFilterBtn = new System.Windows.Forms.Button(); + this.menuStrip1.SuspendLayout(); + this.statusStrip1.SuspendLayout(); + this.SuspendLayout(); + // + // gridPanel + // + this.gridPanel.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.gridPanel.Location = new System.Drawing.Point(12, 56); + this.gridPanel.Name = "gridPanel"; + this.gridPanel.Size = new System.Drawing.Size(839, 386); + this.gridPanel.TabIndex = 5; + // + // filterHelpBtn + // + this.filterHelpBtn.Location = new System.Drawing.Point(12, 27); + this.filterHelpBtn.Name = "filterHelpBtn"; + this.filterHelpBtn.Size = new System.Drawing.Size(22, 23); + this.filterHelpBtn.TabIndex = 3; + this.filterHelpBtn.Text = "?"; + this.filterHelpBtn.UseVisualStyleBackColor = true; + this.filterHelpBtn.Click += new System.EventHandler(this.filterHelpBtn_Click); + // + // filterBtn + // + this.filterBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right))); + this.filterBtn.Location = new System.Drawing.Point(776, 27); + this.filterBtn.Name = "filterBtn"; + this.filterBtn.Size = new System.Drawing.Size(75, 23); + this.filterBtn.TabIndex = 2; + this.filterBtn.Text = "Filter"; + this.filterBtn.UseVisualStyleBackColor = true; + this.filterBtn.Click += new System.EventHandler(this.filterBtn_Click); + // + // filterSearchTb + // + this.filterSearchTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.filterSearchTb.Location = new System.Drawing.Point(186, 29); + this.filterSearchTb.Name = "filterSearchTb"; + this.filterSearchTb.Size = new System.Drawing.Size(584, 20); + this.filterSearchTb.TabIndex = 1; + this.filterSearchTb.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.filterSearchTb_KeyPress); + // + // menuStrip1 + // + this.menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.indexToolStripMenuItem, + this.liberateToolStripMenuItem, + this.quickFiltersToolStripMenuItem, + this.settingsToolStripMenuItem}); + this.menuStrip1.Location = new System.Drawing.Point(0, 0); + this.menuStrip1.Name = "menuStrip1"; + this.menuStrip1.Size = new System.Drawing.Size(863, 24); + this.menuStrip1.TabIndex = 0; + this.menuStrip1.Text = "menuStrip1"; + // + // indexToolStripMenuItem + // + this.indexToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.scanLibraryToolStripMenuItem, + this.reimportMostRecentLibraryScanToolStripMenuItem, + this.beginImportingBookDetailsToolStripMenuItem}); + this.indexToolStripMenuItem.Name = "indexToolStripMenuItem"; + this.indexToolStripMenuItem.Size = new System.Drawing.Size(47, 20); + this.indexToolStripMenuItem.Text = "&Index"; + this.indexToolStripMenuItem.DropDownOpening += new System.EventHandler(this.indexToolStripMenuItem_DropDownOpening); + // + // scanLibraryToolStripMenuItem + // + this.scanLibraryToolStripMenuItem.Name = "scanLibraryToolStripMenuItem"; + this.scanLibraryToolStripMenuItem.Size = new System.Drawing.Size(277, 22); + this.scanLibraryToolStripMenuItem.Text = "Scan &Library..."; + this.scanLibraryToolStripMenuItem.Click += new System.EventHandler(this.scanLibraryToolStripMenuItem_Click); + // + // reimportMostRecentLibraryScanToolStripMenuItem + // + this.reimportMostRecentLibraryScanToolStripMenuItem.Name = "reimportMostRecentLibraryScanToolStripMenuItem"; + this.reimportMostRecentLibraryScanToolStripMenuItem.Size = new System.Drawing.Size(277, 22); + this.reimportMostRecentLibraryScanToolStripMenuItem.Text = "Re-&import most recent library scan: {0}"; + this.reimportMostRecentLibraryScanToolStripMenuItem.Click += new System.EventHandler(this.reimportMostRecentLibraryScanToolStripMenuItem_Click); + // + // beginImportingBookDetailsToolStripMenuItem + // + this.beginImportingBookDetailsToolStripMenuItem.Name = "beginImportingBookDetailsToolStripMenuItem"; + this.beginImportingBookDetailsToolStripMenuItem.Size = new System.Drawing.Size(277, 22); + this.beginImportingBookDetailsToolStripMenuItem.Text = "Begin importing book details: {0}"; + this.beginImportingBookDetailsToolStripMenuItem.Click += new System.EventHandler(this.beginImportingBookDetailsToolStripMenuItem_Click); + // + // liberateToolStripMenuItem + // + this.liberateToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.beginBookBackupsToolStripMenuItem, + this.beginPdfBackupsToolStripMenuItem}); + this.liberateToolStripMenuItem.Name = "liberateToolStripMenuItem"; + this.liberateToolStripMenuItem.Size = new System.Drawing.Size(61, 20); + this.liberateToolStripMenuItem.Text = "&Liberate"; + // + // beginBookBackupsToolStripMenuItem + // + this.beginBookBackupsToolStripMenuItem.Name = "beginBookBackupsToolStripMenuItem"; + this.beginBookBackupsToolStripMenuItem.Size = new System.Drawing.Size(201, 22); + this.beginBookBackupsToolStripMenuItem.Text = "Begin &Book Backups: {0}"; + this.beginBookBackupsToolStripMenuItem.Click += new System.EventHandler(this.beginBookBackupsToolStripMenuItem_Click); + // + // beginPdfBackupsToolStripMenuItem + // + this.beginPdfBackupsToolStripMenuItem.Name = "beginPdfBackupsToolStripMenuItem"; + this.beginPdfBackupsToolStripMenuItem.Size = new System.Drawing.Size(201, 22); + this.beginPdfBackupsToolStripMenuItem.Text = "Begin &PDF Backups: {0}"; + this.beginPdfBackupsToolStripMenuItem.Click += new System.EventHandler(this.beginPdfBackupsToolStripMenuItem_Click); + // + // quickFiltersToolStripMenuItem + // + this.quickFiltersToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.firstFilterIsDefaultToolStripMenuItem, + this.editQuickFiltersToolStripMenuItem, + this.toolStripSeparator1}); + this.quickFiltersToolStripMenuItem.Name = "quickFiltersToolStripMenuItem"; + this.quickFiltersToolStripMenuItem.Size = new System.Drawing.Size(84, 20); + this.quickFiltersToolStripMenuItem.Text = "Quick &Filters"; + // + // firstFilterIsDefaultToolStripMenuItem + // + this.firstFilterIsDefaultToolStripMenuItem.Name = "firstFilterIsDefaultToolStripMenuItem"; + this.firstFilterIsDefaultToolStripMenuItem.Size = new System.Drawing.Size(256, 22); + this.firstFilterIsDefaultToolStripMenuItem.Text = "Start Libation with 1st filter &Default"; + this.firstFilterIsDefaultToolStripMenuItem.Click += new System.EventHandler(this.FirstFilterIsDefaultToolStripMenuItem_Click); + // + // editQuickFiltersToolStripMenuItem + // + this.editQuickFiltersToolStripMenuItem.Name = "editQuickFiltersToolStripMenuItem"; + this.editQuickFiltersToolStripMenuItem.Size = new System.Drawing.Size(256, 22); + this.editQuickFiltersToolStripMenuItem.Text = "&Edit quick filters"; + this.editQuickFiltersToolStripMenuItem.Click += new System.EventHandler(this.EditQuickFiltersToolStripMenuItem_Click); + // + // toolStripSeparator1 + // + this.toolStripSeparator1.Name = "toolStripSeparator1"; + this.toolStripSeparator1.Size = new System.Drawing.Size(253, 6); + // + // settingsToolStripMenuItem + // + this.settingsToolStripMenuItem.Name = "settingsToolStripMenuItem"; + this.settingsToolStripMenuItem.Size = new System.Drawing.Size(61, 20); + this.settingsToolStripMenuItem.Text = "&Settings"; + this.settingsToolStripMenuItem.Click += new System.EventHandler(this.settingsToolStripMenuItem_Click); + // + // statusStrip1 + // + this.statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.visibleCountLbl, + this.springLbl, + this.backupsCountsLbl, + this.pdfsCountsLbl}); + this.statusStrip1.Location = new System.Drawing.Point(0, 445); + this.statusStrip1.Name = "statusStrip1"; + this.statusStrip1.Size = new System.Drawing.Size(863, 22); + this.statusStrip1.TabIndex = 6; + this.statusStrip1.Text = "statusStrip1"; + // + // visibleCountLbl + // + this.visibleCountLbl.Name = "visibleCountLbl"; + this.visibleCountLbl.Size = new System.Drawing.Size(61, 17); + this.visibleCountLbl.Text = "Visible: {0}"; + // + // springLbl + // + this.springLbl.Name = "springLbl"; + this.springLbl.Size = new System.Drawing.Size(232, 17); + this.springLbl.Spring = true; + // + // backupsCountsLbl + // + this.backupsCountsLbl.Name = "backupsCountsLbl"; + this.backupsCountsLbl.Size = new System.Drawing.Size(336, 17); + this.backupsCountsLbl.Text = "BACKUPS: No progress: {0} Encrypted: {1} Fully backed up: {2}"; + // + // pdfsCountsLbl + // + this.pdfsCountsLbl.Name = "pdfsCountsLbl"; + this.pdfsCountsLbl.Size = new System.Drawing.Size(219, 17); + this.pdfsCountsLbl.Text = "| PDFs: NOT d/l\'ed: {0} Downloaded: {1}"; + // + // addFilterBtn + // + this.addFilterBtn.Location = new System.Drawing.Point(40, 27); + this.addFilterBtn.Name = "addFilterBtn"; + this.addFilterBtn.Size = new System.Drawing.Size(140, 23); + this.addFilterBtn.TabIndex = 4; + this.addFilterBtn.Text = "Add To Quick Filters"; + this.addFilterBtn.UseVisualStyleBackColor = true; + this.addFilterBtn.Click += new System.EventHandler(this.AddFilterBtn_Click); + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(863, 467); + this.Controls.Add(this.filterBtn); + this.Controls.Add(this.addFilterBtn); + this.Controls.Add(this.filterSearchTb); + this.Controls.Add(this.filterHelpBtn); + this.Controls.Add(this.statusStrip1); + this.Controls.Add(this.gridPanel); + this.Controls.Add(this.menuStrip1); + this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon"))); + this.MainMenuStrip = this.menuStrip1; + this.Name = "Form1"; + this.Text = "Libation: Liberate your Library"; + this.Load += new System.EventHandler(this.Form1_Load); + this.menuStrip1.ResumeLayout(false); + this.menuStrip1.PerformLayout(); + this.statusStrip1.ResumeLayout(false); + this.statusStrip1.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + private System.Windows.Forms.Panel gridPanel; + private System.Windows.Forms.MenuStrip menuStrip1; + private System.Windows.Forms.ToolStripMenuItem indexToolStripMenuItem; + private System.Windows.Forms.StatusStrip statusStrip1; + private System.Windows.Forms.ToolStripStatusLabel springLbl; + private System.Windows.Forms.ToolStripStatusLabel visibleCountLbl; + private System.Windows.Forms.ToolStripMenuItem liberateToolStripMenuItem; + private System.Windows.Forms.ToolStripStatusLabel backupsCountsLbl; + private System.Windows.Forms.ToolStripMenuItem beginBookBackupsToolStripMenuItem; + private System.Windows.Forms.ToolStripStatusLabel pdfsCountsLbl; + private System.Windows.Forms.ToolStripMenuItem beginPdfBackupsToolStripMenuItem; + private System.Windows.Forms.TextBox filterSearchTb; + private System.Windows.Forms.Button filterBtn; + private System.Windows.Forms.Button filterHelpBtn; + private System.Windows.Forms.ToolStripMenuItem settingsToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem scanLibraryToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem reimportMostRecentLibraryScanToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem beginImportingBookDetailsToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem quickFiltersToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem firstFilterIsDefaultToolStripMenuItem; + private System.Windows.Forms.Button addFilterBtn; + private System.Windows.Forms.ToolStripMenuItem editQuickFiltersToolStripMenuItem; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator1; + } +} + diff --git a/LibationWinForm/UNTESTED/Form1.cs b/LibationWinForm/UNTESTED/Form1.cs new file mode 100644 index 00000000..cd1f9285 --- /dev/null +++ b/LibationWinForm/UNTESTED/Form1.cs @@ -0,0 +1,473 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using DomainServices; +using DataLayer; +using Dinah.Core; +using Dinah.Core.Collections.Generic; +using Dinah.Core.Windows.Forms; +using FileManager; + +namespace LibationWinForm +{ + public partial class Form1 : Form + { + // initial call here will initiate config loading + private Configuration config = Configuration.Instance; + + private string backupsCountsLbl_Format; + private string pdfsCountsLbl_Format; + private string visibleCountLbl_Format; + + private string reimportMostRecentLibraryScanToolStripMenuItem_format; + private string beginImportingBookDetailsToolStripMenuItem_format; + + private string beginBookBackupsToolStripMenuItem_format; + private string beginPdfBackupsToolStripMenuItem_format; + + public Form1() + { + InitializeComponent(); + + // back up string formats + backupsCountsLbl_Format = backupsCountsLbl.Text; + pdfsCountsLbl_Format = pdfsCountsLbl.Text; + visibleCountLbl_Format = visibleCountLbl.Text; + + reimportMostRecentLibraryScanToolStripMenuItem_format = reimportMostRecentLibraryScanToolStripMenuItem.Text; + beginImportingBookDetailsToolStripMenuItem_format = beginImportingBookDetailsToolStripMenuItem.Text; + + beginBookBackupsToolStripMenuItem_format = beginBookBackupsToolStripMenuItem.Text; + beginPdfBackupsToolStripMenuItem_format = beginPdfBackupsToolStripMenuItem.Text; + } + + private async void Form1_Load(object sender, EventArgs e) + { + // call static ctor. There are bad race conditions if static ctor is first executed when we're running in parallel in setBackupCountsAsync() + var foo = FilePathCache.JsonFile; + + reloadGrid(); + + // also applies filter. ONLY call AFTER loading grid + loadInitialQuickFilterState(); + + { // init bottom counts + backupsCountsLbl.Text = "[Calculating backed up book quantities]"; + pdfsCountsLbl.Text = "[Calculating backed up PDFs]"; + + await setBackupCountsAsync(); + } + } + + #region bottom: qty books visible + public void SetVisibleCount(int qty, string str = null) + { + visibleCountLbl.Text = string.Format(visibleCountLbl_Format, qty); + + if (!string.IsNullOrWhiteSpace(str)) + visibleCountLbl.Text += " | " + str; + } + #endregion + + #region bottom: backup counts + private async Task setBackupCountsAsync() + { + var books = LibraryQueries.GetLibrary_Flat_NoTracking() + .Select(sp => sp.Book) + .ToList(); + + await setBookBackupCountsAsync(books).ConfigureAwait(false); + await setPdfBackupCountsAsync(books).ConfigureAwait(false); + } + enum AudioFileState { full, aax, none } + private async Task setBookBackupCountsAsync(IEnumerable books) + { + var libraryProductIds = books + .Select(b => b.AudibleProductId) + .ToList(); + + var noProgress = 0; + var downloadedOnly = 0; + var fullyBackedUp = 0; + + + //// serial + //foreach (var productId in libraryProductIds) + //{ + // if (await AudibleFileStorage.Audio.ExistsAsync(productId)) + // fullyBackedUp++; + // else if (await AudibleFileStorage.AAX.ExistsAsync(productId)) + // downloadedOnly++; + // else + // noProgress++; + //} + + // parallel + async Task getAudioFileStateAsync(string productId) + { + if (await AudibleFileStorage.Audio.ExistsAsync(productId)) + return AudioFileState.full; + if (await AudibleFileStorage.AAX.ExistsAsync(productId)) + return AudioFileState.aax; + return AudioFileState.none; + } + var tasks = libraryProductIds.Select(productId => getAudioFileStateAsync(productId)); + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + fullyBackedUp = results.Count(r => r == AudioFileState.full); + downloadedOnly = results.Count(r => r == AudioFileState.aax); + noProgress = results.Count(r => r == AudioFileState.none); + + // update bottom numbers + var pending = noProgress + downloadedOnly; + var text + = !results.Any() ? "No books. Begin by indexing your library" + : pending > 0 ? string.Format(backupsCountsLbl_Format, noProgress, downloadedOnly, fullyBackedUp) + : $"All {"book".PluralizeWithCount(fullyBackedUp)} backed up"; + statusStrip1.UIThread(() => backupsCountsLbl.Text = text); + + // update menu item + var menuItemText + = pending > 0 + ? $"{pending} remaining" + : "All books have been liberated"; + menuStrip1.UIThread(() => beginBookBackupsToolStripMenuItem.Enabled = pending > 0); + menuStrip1.UIThread(() => beginBookBackupsToolStripMenuItem.Text = string.Format(beginBookBackupsToolStripMenuItem_format, menuItemText)); + } + private async Task setPdfBackupCountsAsync(IEnumerable books) + { + var libraryProductIds = books + .Where(b => b.Supplements.Any()) + .Select(b => b.AudibleProductId) + .ToList(); + + int notDownloaded; + int downloaded; + + //// serial + //notDownloaded = 0; + //downloaded = 0; + //foreach (var productId in libraryProductIds) + //{ + // if (await AudibleFileStorage.PDF.ExistsAsync(productId)) + // downloaded++; + // else + // notDownloaded++; + //} + + // parallel + var tasks = libraryProductIds.Select(productId => AudibleFileStorage.PDF.ExistsAsync(productId)); + var boolResults = await Task.WhenAll(tasks).ConfigureAwait(false); + downloaded = boolResults.Count(r => r); + notDownloaded = boolResults.Count(r => !r); + + // update bottom numbers + var text + = !boolResults.Any() ? "" + : notDownloaded > 0 ? string.Format(pdfsCountsLbl_Format, notDownloaded, downloaded) + : $"| All {downloaded} PDFs downloaded"; + statusStrip1.UIThread(() => pdfsCountsLbl.Text = text); + + // update menu item + var menuItemText + = notDownloaded > 0 + ? $"{notDownloaded} remaining" + : "All PDFs have been downloaded"; + menuStrip1.UIThread(() => beginPdfBackupsToolStripMenuItem.Enabled = notDownloaded > 0); + menuStrip1.UIThread(() => beginPdfBackupsToolStripMenuItem.Text = string.Format(beginPdfBackupsToolStripMenuItem_format, menuItemText)); + } + #endregion + + #region grid select + bool isProcessingGridSelect = false; + private void reloadGrid() + { + // suppressed filter while init'ing UI + var prev_isProcessingGridSelect = isProcessingGridSelect; + isProcessingGridSelect = true; + setGrid(); + isProcessingGridSelect = prev_isProcessingGridSelect; + + // UI init complete. now we can apply filter + doFilter(lastGoodFilter); + } + + ProductsGrid currProductsGrid; + private void setGrid() + { + SuspendLayout(); + { + if (currProductsGrid != null) + { + gridPanel.Controls.Remove(currProductsGrid); + currProductsGrid.Dispose(); + } + + currProductsGrid = new ProductsGrid(this) { Dock = DockStyle.Fill }; + gridPanel.Controls.Add(currProductsGrid); + currProductsGrid.Display(); + } + ResumeLayout(); + } + #endregion + + #region filter + private void filterHelpBtn_Click(object sender, EventArgs e) => new Dialogs.SearchSyntaxDialog().ShowDialog(); + + private void AddFilterBtn_Click(object sender, EventArgs e) + { + QuickFilters.Add(this.filterSearchTb.Text); + UpdateFilterDropDown(); + } + + private void filterSearchTb_KeyPress(object sender, KeyPressEventArgs e) + { + if (e.KeyChar == (char)Keys.Return) + { + doFilter(); + + // silence the 'ding' + e.Handled = true; + } + } + private void filterBtn_Click(object sender, EventArgs e) => doFilter(); + + string lastGoodFilter = ""; + private void doFilter(string filterString) + { + this.filterSearchTb.Text = filterString; + doFilter(); + } + private void doFilter() + { + if (isProcessingGridSelect || currProductsGrid == null) + return; + + try + { + currProductsGrid.Filter(filterSearchTb.Text); + lastGoodFilter = filterSearchTb.Text; + } + catch (Exception ex) + { + MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error); + + // re-apply last good filter + filterSearchTb.Text = lastGoodFilter; + doFilter(); + } + } + #endregion + + #region index menu + // + // IMPORTANT + // + // IRunnableDialog.Run() extension method contains work flow + // + #region // example code: chaining multiple dialogs + public class MyDialog1 : IRunnableDialog + { + public IEnumerable Files; + + public IButtonControl AcceptButton { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public Control.ControlCollection Controls => throw new NotImplementedException(); + public string SuccessMessage => throw new NotImplementedException(); + public DialogResult DialogResult { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + public void Close() => throw new NotImplementedException(); + public Task DoMainWorkAsync() => throw new NotImplementedException(); + public DialogResult ShowDialog() => throw new NotImplementedException(); + public string StringBasedValidate() => throw new NotImplementedException(); + } + public class MyDialog2 : Form, IIndexLibraryDialog + { + public MyDialog2(IEnumerable files) { } + Button BeginFileImportBtn = new Button(); + + public void Begin() => BeginFileImportBtn.PerformClick(); + + public int TotalBooksProcessed => throw new NotImplementedException(); + public int NewBooksAdded => throw new NotImplementedException(); + public string SuccessMessage => throw new NotImplementedException(); + public Task DoMainWorkAsync() => throw new NotImplementedException(); + public string StringBasedValidate() => throw new NotImplementedException(); + } + private async void downloadPagesToFile(object sender, EventArgs e) + { + var dialog1 = new MyDialog1(); + if (dialog1.RunDialog() != DialogResult.OK || !dialog1.Files.Any()) + return; + + if (MessageBox.Show("Index from these files?", "Index?", MessageBoxButtons.YesNo) == DialogResult.Yes) + { + var dialog2 = new MyDialog2(dialog1.Files); + dialog2.Shown += (_, __) => dialog2.Begin(); + await indexDialog(dialog2); + } + } + #endregion + + private void indexToolStripMenuItem_DropDownOpening(object sender, EventArgs e) + { + #region label: Re-import most recent library scan + { + var libDir = WebpageStorage.GetMostRecentLibraryDir(); + if (libDir == null) + { + reimportMostRecentLibraryScanToolStripMenuItem.Enabled = false; + reimportMostRecentLibraryScanToolStripMenuItem.Text = string.Format(reimportMostRecentLibraryScanToolStripMenuItem_format, "No previous scans"); + } + else + { + reimportMostRecentLibraryScanToolStripMenuItem.Enabled = true; + + var now = DateTime.Now; + var span = now - libDir.CreationTime; + var ago + // less than 1 min + = (int)span.TotalSeconds < 60 ? $"{(int)span.TotalSeconds} sec ago" + // less than 1 hr + : (int)span.TotalMinutes < 60 ? $"{(int)span.TotalMinutes} min ago" + // today. eg: 4:25 PM + : now.Date == libDir.CreationTime.Date ? libDir.CreationTime.ToString("h:mm tt") + // else date and time + : libDir.CreationTime.ToString("MM/dd/yyyy h:mm tt"); + reimportMostRecentLibraryScanToolStripMenuItem.Text = string.Format(reimportMostRecentLibraryScanToolStripMenuItem_format, ago); + } + } + #endregion + + #region label: Begin importing book details + { + var noDetails = BookQueries.BooksWithoutDetailsCount(); + if (noDetails == 0) + { + beginImportingBookDetailsToolStripMenuItem.Enabled = false; + beginImportingBookDetailsToolStripMenuItem.Text = string.Format(beginImportingBookDetailsToolStripMenuItem_format, "No books without details"); + } + else + { + beginImportingBookDetailsToolStripMenuItem.Enabled = true; + beginImportingBookDetailsToolStripMenuItem.Text = string.Format(beginImportingBookDetailsToolStripMenuItem_format, $"{noDetails} remaining"); + } + } + #endregion + } + + private async void scanLibraryToolStripMenuItem_Click(object sender, EventArgs e) => await indexDialog(new ScanLibraryDialog()); + + private async void reimportMostRecentLibraryScanToolStripMenuItem_Click(object sender, EventArgs e) + { + // DO NOT ConfigureAwait(false) + // this would result in index() => reloadGrid() => setGrid() => "gridPanel.Controls.Remove(currProductsGrid);" + // throwing 'Cross-thread operation not valid: Control 'ProductsGrid' accessed from a thread other than the thread it was created on.' + var (TotalBooksProcessed, NewBooksAdded) = await Indexer.IndexLibraryAsync(WebpageStorage.GetMostRecentLibraryDir()); + + MessageBox.Show($"Total processed: {TotalBooksProcessed}\r\nNew: {NewBooksAdded}"); + + await index(NewBooksAdded, TotalBooksProcessed); + } + + private async Task indexDialog(IIndexLibraryDialog dialog) + { + if (!dialog.RunDialog().In(DialogResult.Abort, DialogResult.Cancel, DialogResult.None)) + await index(dialog.NewBooksAdded, dialog.TotalBooksProcessed); + } + private async Task index(int newBooksAdded, int totalBooksProcessed) + { + // update backup counts if we have new library items + if (newBooksAdded > 0) + await setBackupCountsAsync(); + + // skip reload if: + // - no grid is loaded + // - none indexed + if (currProductsGrid == null || totalBooksProcessed == 0) + return; + + reloadGrid(); + } + + private void updateGridRow(object _, string productId) => currProductsGrid?.UpdateRow(productId); + + private async void beginImportingBookDetailsToolStripMenuItem_Click(object sender, EventArgs e) + { + var scrapeBookDetails = BookLiberation.ProcessorAutomationController.GetWiredUpScrapeBookDetails(); + scrapeBookDetails.BookSuccessfullyImported += updateGridRow; + await BookLiberation.ProcessorAutomationController.RunAutomaticDownload(scrapeBookDetails); + } + #endregion + + #region liberate menu + private async void setBackupCountsAsync(object _, string __) => await setBackupCountsAsync(); + + private async void beginBookBackupsToolStripMenuItem_Click(object sender, EventArgs e) + { + var backupBook = BookLiberation.ProcessorAutomationController.GetWiredUpBackupBook(); + backupBook.Download.Completed += setBackupCountsAsync; + backupBook.Decrypt.Completed += setBackupCountsAsync; + await BookLiberation.ProcessorAutomationController.RunAutomaticBackup(backupBook); + } + + private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e) + { + var downloadPdf = BookLiberation.ProcessorAutomationController.GetWiredUpDownloadPdf(); + downloadPdf.Completed += setBackupCountsAsync; + await BookLiberation.ProcessorAutomationController.RunAutomaticDownload(downloadPdf); + } + #endregion + + #region quick filters menu + private void loadInitialQuickFilterState() + { + // set inital state. do once only + firstFilterIsDefaultToolStripMenuItem.Checked = QuickFilters.UseDefault; + + // load default filter. do once only + if (QuickFilters.UseDefault) + doFilter(QuickFilters.Filters.FirstOrDefault()); + + // do after every save + UpdateFilterDropDown(); + } + + private void FirstFilterIsDefaultToolStripMenuItem_Click(object sender, EventArgs e) + { + firstFilterIsDefaultToolStripMenuItem.Checked = !firstFilterIsDefaultToolStripMenuItem.Checked; + QuickFilters.UseDefault = firstFilterIsDefaultToolStripMenuItem.Checked; + } + + object quickFilterTag { get; } = new object(); + public void UpdateFilterDropDown() + { + // remove old + for (var i = quickFiltersToolStripMenuItem.DropDownItems.Count - 1; i >= 0; i--) + { + var menuItem = quickFiltersToolStripMenuItem.DropDownItems[i]; + if (menuItem.Tag == quickFilterTag) + quickFiltersToolStripMenuItem.DropDownItems.Remove(menuItem); + } + + // re-populate + var index = 0; + foreach (var filter in QuickFilters.Filters) + { + var menuItem = new ToolStripMenuItem + { + Tag = quickFilterTag, + Text = $"&{++index}: {filter}" + }; + menuItem.Click += (_, __) => doFilter(filter); + quickFiltersToolStripMenuItem.DropDownItems.Add(menuItem); + } + } + + private void EditQuickFiltersToolStripMenuItem_Click(object sender, EventArgs e) => new Dialogs.EditQuickFilters(this).ShowDialog(); + #endregion + + #region settings menu item + private void settingsToolStripMenuItem_Click(object sender, EventArgs e) => new SettingsDialog().ShowDialog(); + #endregion + } +} diff --git a/LibationWinForm/UNTESTED/Form1.resx b/LibationWinForm/UNTESTED/Form1.resx new file mode 100644 index 00000000..0a62a202 --- /dev/null +++ b/LibationWinForm/UNTESTED/Form1.resx @@ -0,0 +1,1695 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + + 132, 17 + + + + + AAABAAUAEBAAAAEAIABoBAAAVgAAACAgAAABACAAqBAAAL4EAABAQAAAAQAgAChCAABmFQAAgIAAAAEA + IAAoCAEAjlcAAAAAAAABACAAzg4AALZfAQAoAAAAEAAAACAAAAABACAAAAAAAAAEAADXDQAA1w0AAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAIAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAACAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAA + AIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+AAAArQAA + AO8AAADvAAAArQAAAD4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABtAAAA5AAA + AHcAAAByAAAAcgAAAHcAAADkAAAAbQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfAAAA6AAA + AGsAAADzAAAA/wAAAP8AAADzAAAAawAAAOgAAAAfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAiwAA + AIkAAADlAAAA/wAAAP8AAAD/AAAA/wAAAOUAAACJAAAAiwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + ALkAAACBAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAgQAAALkAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAADeAAAAeQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAHoAAADeAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAywAAAC0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAxAAAAywAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAK0AAABSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWQAAAK0AAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAABWAAAAvgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAABWAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAANEAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADRAAAAAQAA + AAAAAAAAAAAAAPAPAAD+fwAA/n8AAP5/AAD+fwAA/n8AAPgfAADwDwAA4AcAAOAHAADgBwAA4AcAAOfn + AADn5wAA5+cAAOAHAAAoAAAAIAAAAEAAAAABACAAAAAAAAAQAADXDQAA1w0AAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAuAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAALgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC4AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAADAAAAOwAAAIAAAADFAAAA/wAAAP8AAADFAAAAgAAAADsAAAADAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAKgAAAMsAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AMsAAAAqAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHMAAAD4AAAA/wAAAOcAAACFAAAAVgAAACcAAAAnAAAAVgAA + AIUAAADnAAAA/wAAAPgAAABzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABjAAAA/wAAAP4AAACYAAAAFAAAAGAAAACWAAAAwgAA + AMIAAACXAAAAYgAAABUAAACYAAAA/gAAAP8AAABjAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIgAAAPcAAAD/AAAAZQAAAD0AAADTAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA1QAAAD4AAABlAAAA/wAAAPcAAAAiAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACXAAAA/wAAAMkAAAAbAAAA9gAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA9gAAABsAAADJAAAA/wAAAJcAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAPcAAAD+AAAANgAA + AJYAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAlgAAADYAAAD+AAAA9wAA + AAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAxAAAA/wAA + AMwAAAAiAAAA+gAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD6AAAAIgAA + AMwAAAD/AAAAMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AF0AAAD/AAAAngAAAGgAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAABoAAAAngAAAP8AAABdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAiQAAAP8AAABwAAAAhgAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAIYAAABwAAAA/wAAAIkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAC1AAAA/wAAAEIAAACjAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAAowAAAEIAAAD/AAAAtQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMMAAAD/AAAAPQAAAMEAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADBAAAAPQAAAP8AAADDAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApQAAAP8AAABTAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTAAAA/wAAAKUAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACHAAAA/wAAAGoAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGoAAAD/AAAAhwAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGkAAAD/AAAAggAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAggAA + AP8AAABpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASgAA + AP8AAADUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAADUAAAA/wAAAEoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAANAAAA9AAAAP8AAABGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAARgAAAP8AAAD0AAAADQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAACeAAAA/wAAALcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAC3AAAA/wAAAJ4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAADyAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADyAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGEAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGEAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAP+AAf//gAH///5////+f////n////5////+f////n////5////+f////n////5////g + B///wAP//4AB//8AAP/+AAB//gAAf/wAAD/8AAA//AAAP/wAAD/8AAA//AAAP/x//j/8f/4//H/+P/x/ + /j/8P/w//j/8f/4AAH//AAD/KAAAAEAAAACAAAAAAQAgAAAAAAAAQAAA1w0AANcNAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAADwAAADJAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAyQAA + ADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADJAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAADJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyQAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAyQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + ADwAAADJAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAyQAAADwAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAA + AP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA + AP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAA + AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAA + AP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAHgAAAFkAAACJAAAAuAAAAP8AAAD/AAAA/wAAAP8AAAC4AAAAiQAAAFkAAAAeAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAABAAAABnAAAAwgAAAP4AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/gAAAMEAAABlAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAABgAAAHIAAADuAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAOwAAABwAAAABgAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAN0AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AN0AAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbgAAAPsAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAADYAAAAjgAAAF0AAABIAAAAMgAAADIAAABIAAAAXQAAAI4AAADYAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA+wAAAG4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmAAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA9QAAAI0AAAAiAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAACIAAACNAAAA9QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAmAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAbAAAAP8AAAD/AAAA/wAAAP8AAAD+AAAAkQAAABUAAAAAAAAAAAAAAAAAAAAXAAAAQwAA + AG4AAACaAAAAmgAAAG4AAABDAAAAFwAAAAAAAAAAAAAAAAAAABUAAACRAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAABsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAANwAAAPkAAAD/AAAA/wAAAP8AAADwAAAATwAAAAAAAAAAAAAALgAA + AKUAAADsAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADsAAAApQAAAC4AAAAAAAAAAAAA + AFIAAADyAAAA/wAAAP8AAAD/AAAA+QAAADcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQAAAOIAAAD/AAAA/wAAAP8AAADrAAAAJQAA + AAAAAAAQAAAAqQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAqQAAAA8AAAAAAAAAKgAAAO0AAAD/AAAA/wAAAP8AAADiAAAAEQAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAD/AAAA/wAA + AP8AAAD9AAAARQAAAAAAAAAvAAAA3QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADaAAAAKwAAAAAAAABJAAAA/QAAAP8AAAD/AAAA/wAA + AH4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAgAAADqAAAA/wAAAP8AAAD/AAAAfwAAAAAAAAATAAAA5AAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAOEAAAARAAAAAAAA + AIAAAAD/AAAA/wAAAP8AAADpAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAABnAAAA/wAAAP8AAAD/AAAA5gAAAAYAAAAAAAAAsQAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAArwAAAAAAAAAGAAAA5gAAAP8AAAD/AAAA/wAAAGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2AAAAP8AAAD/AAAA/wAAAHkAAAAAAAAAXgAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABdAAAAAAAAAHkAAAD/AAAA/wAAAP8AAADWAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAP8AAAD/AAAA/wAA + APUAAAASAAAAAAAAAMoAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAygAAAAAAAAASAAAA9QAA + AP8AAAD/AAAA/wAAACYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AGgAAAD/AAAA/wAAAP8AAACoAAAAAAAAAC4AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAAuAAAAAAAAAKgAAAD/AAAA/wAAAP8AAABnAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAACpAAAA/wAAAP8AAAD/AAAAZwAAAAAAAACSAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAAkgAAAAAAAABnAAAA/wAAAP8AAAD/AAAAqAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6AAAAP8AAAD/AAAA/wAAACYAAAAAAAAA1gAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAANYAAAAAAAAAJgAAAP8AAAD/AAAA/wAA + AOgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIQAAAP8AAAD/AAAA/wAA + AOYAAAAAAAAACQAAAPwAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD8AAAACQAA + AAAAAADmAAAA/wAAAP8AAAD/AAAAIQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AD4AAAD/AAAA/wAAAP8AAAC9AAAAAAAAADYAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAADYAAAAAAAAAvQAAAP8AAAD/AAAA/wAAAD4AAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAABYAAAA/wAAAP8AAAD/AAAAoAAAAAAAAABmAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABmAAAAAAAAAKAAAAD/AAAA/wAAAP8AAABYAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcwAAAP8AAAD/AAAA/wAAAIQAAAAAAAAAfwAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAfgAAAAAAAACEAAAA/wAA + AP8AAAD/AAAAcwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI0AAAD/AAAA/wAA + AP8AAABoAAAAAAAAAIsAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AIcAAAAAAAAAaAAAAP8AAAD/AAAA/wAAAI0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAACbAAAA/wAAAP8AAAD/AAAAZAAAAAAAAACWAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAACRAAAAAAAAAGQAAAD/AAAA/wAAAP8AAACbAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAkQAAAP8AAAD/AAAA/wAAAGoAAAAAAAAAogAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAmwAAAAAAAABqAAAA/wAAAP8AAAD/AAAAkQAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIUAAAD/AAAA/wAAAP8AAABwAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAA + AP8AAAD/AAAA/wAAAIUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB6AAAA/wAA + AP8AAAD/AAAAdwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAHcAAAD/AAAA/wAAAP8AAAB6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAbgAAAP8AAAD/AAAA/wAAAJQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUAAAA/wAAAP8AAAD/AAAAbgAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFMAAAD/AAAA/wAAAP8AAAC7AAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAuwAAAP8AAAD/AAAA/wAA + AFMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhAAAA/wAAAP8AAAD/AAAA4gAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AOIAAAD/AAAA/wAAAP8AAAAhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AO8AAAD/AAAA/wAAAP4AAAANAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAA0AAAD+AAAA/wAAAP8AAADvAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAC+AAAA/wAAAP8AAAD/AAAAVgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABWAAAA/wAAAP8AAAD/AAAAvgAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAigAAAP8AAAD/AAAA/wAAAKkAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAqQAAAP8AAAD/AAAA/wAA + AIoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADYAAAD/AAAA/wAA + AP8AAADzAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAA + APMAAAD/AAAA/wAAAP8AAAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAA1AAAAP8AAAD/AAAA/wAAAGcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAGcAAAD/AAAA/wAAAP8AAADQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHIAAAD/AAAA/wAAAP8AAADqAAAADwAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AAADqAAAA/wAAAP8AAAD/AAAAbAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAAAA+gAAAP8AAAD/AAAA/wAA + AI4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACOAAAA/wAAAP8AAAD/AAAA9wAA + ABEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AIAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAHkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAEAAAAzAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAMkAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACsAAAD4AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAPcAAAApAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaAAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABoAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAD//8AAAAP/////wAAAA//////AAAAD/////8AAAAP///////w//////////D/////////8P/////// + //w//////////D/////////8P/////////w//////////D/////////8P/////////w//////////D// + ///////8P/////////w//////////D/////////8P/////////w//////////D/////////8P/////// + //w//////////D/////////AA////////gAAf//////4AAAf//////AAAA//////4AAAB//////AH/gD + /////4BwDgH/////AYABgP////4CAABAf////gQAACB////8CAAAED////wIAAAQP////BAAAAg////4 + EAAACB////ggAAAEH///+CAAAAQf///4IAAABB////BAAAACD///8EAAAAIP///wQAAAAg////BAAAAC + D///8EAAAAIP///wQAAAAg////BAAAACD///8H////4P///wf////g////B////+D///8H////4P///w + f////g////g////8H///+D////wf///4P////B////gf///4H////B////g////8D///8D////wP///w + P////gAAAAB////+AAAAAH////8AAAAA/////4AAAAH//ygAAACAAAAAAAEAAAEAIAAAAAAAAAABANcN + AADXDQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAAAKcAAADmAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA5gAAAKcAAAAdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAB8AAADwAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAO0AAAAcAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApwAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAKcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAADmAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA5gAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOYAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAApwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AKcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfAAAA8AAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADtAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfAAAApwAAAOYAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADmAAAApwAA + AB0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAApAAAAWQAAAIgAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAIgAAABZAAAAKQAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAZgAA + AJwAAADLAAAA9wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA9wAAAMsAAACcAAAAZgAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAsAAABaAAAAtQAAAPsAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA+wAA + ALUAAABaAAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEUAAAClAAAA9AAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD0AAAApQAAAEUAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + ADAAAAC9AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAL0AAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABsAAACfAAAA/QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP0AAACfAAAAGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAACCAAAA9QAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD1AAAAggAAAAoAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAiAAAA0wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA0wAAACIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQwAAAO0AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA8wAA + AMYAAACvAAAAmAAAAIEAAABqAAAAUwAAAFMAAABqAAAAgQAAAJgAAACvAAAAxgAAAPIAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA7QAA + AEMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAHAAAAD8AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP4AAADPAAAAiAAAAEEAAAAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAD8AAACGAAAAzgAAAP4AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/AAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAACgAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAANEAAABgAAAAFwAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAFgAAAGAAAADRAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAKAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAEAAAAtgAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + ANAAAABSAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAABSAAAA0AAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAALYAAAAEAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJwAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAM8AAABRAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAIAAAAVAAAAKgAAAD8AAABVAAAAVQAAAEAAAAAqAAAAFQAAAAIAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAUQAAAM8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAJwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAB/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAPwAAAB0AAAAAQAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAVwAAAJsAAADdAAAA/QAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/QAAAN0AAACbAAAAVwAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAQAAAHQAAAD8AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AH8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAADpAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMAAAB+AAAAzwAA + AP4AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/gAAAM8AAAB+AAAAEwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAADpAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAEkAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + ABAAAADkAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAxwAAABsAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAABwAAACPAAAA9AAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD0AAAAjwAA + ABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABsAAADHAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA5AAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAJgAAAAGAAAAAAAAAAAAAAAAAAAAAAAAABUAAACcAAAA+QAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA+QAAAJwAAAAVAAAAAAAAAAAAAAAAAAAAAAAA + AAYAAACYAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAoQAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AEUAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAACyAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAA8AAAA5AAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAOQAAAA8AAAAAAAAAAAAAAAAAAAAAAAAAAEAAACyAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAARQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAvwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA3gAAABAAAAAAAAAAAAAAAAAAAAABAAAAdQAAAPsAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAPsAAAB1AAAAAQAA + AAAAAAAAAAAAAAAAABAAAADeAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + ADMAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAPgAAAAyAAAAAAAAAAAAAAAAAAAACgAA + ALIAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAACyAAAACgAAAAAAAAAAAAAAAAAAADIAAAD4AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAA1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAZwAAAAAAAAAAAAAAAAAAAAIAAACuAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAACuAAAAAgAAAAAAAAAAAAAAAAAAAGcAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AKoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AB8AAAD8AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAKQAAAAAAAAAAAAAAAAAAAAAAAAAiwAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAACLAAAAAAAAAAAAAAAAAAAAAAAA + AKQAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/AAAACMAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD6AAAAGgAAAAAAAAAAAAAAAAAAAEkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAABJAAAAAAAAAAAAAAAAAAAAGgAAAPoAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAlQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + ABAAAADzAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAKIAAAAAAAAAAAAAAAAAAAAIAAAA3AAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAANwAAAAIAAAAAAAA + AAAAAAAAAAAAogAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD2AAAAFAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAMAAAAAAAAAAAAAAAAAAAAIAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAIAAAAAAAAAAAAAAAAAAAAAwAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAB/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAEAAADlAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAL0AAAAAAAAAAAAAAAAAAAAKAAAA8QAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA8QAA + AAoAAAAAAAAAAAAAAAAAAAC9AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAOoAAAADAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAASwAAAAAAAAAAAAAAAAAAAGAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAYAAAAAAAAAAAAAAAAAAAAEsAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAADYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAABxAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAANcAAAABAAAAAAAAAAAAAAAAAAAAxAAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAADEAAAAAAAAAAAAAAAAAAAAAQAAANcAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAdwAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALIAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAcwAAAAAAAAAAAAAAAAAAACkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAApAAAAAAAAAAAAAAAAAAAAcwAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAC3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAADAAAA8AAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAwAAAAAAAAAAAAAAAAAAAAjQAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAI0AAAAAAAAAAAAAAAAAAAAwAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + APMAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADUAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA7QAAAAIAAAAAAAAAAAAAAAYAAADrAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA6wAAAAYAAAAAAAAAAAAA + AAEAAADtAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAADkAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAdwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAACuAAAAAAAAAAAAAAAAAAAAVQAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAAVQAAAAAAAAAAAAAAAAAAAK4AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAeQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC4AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAG0AAAAAAAAAAAAAAAAAAACUAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAACUAAAAAAAA + AAAAAAAAAAAAbQAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAC6AAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAABQAAAPQAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAALAAAAAAAAAAAAAAAAAAA + AMQAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAMQAAAAAAAAAAAAAAAAAAAAsAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAPUAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAOoAAAABAAAAAAAAAAAAAAABAAAA8wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA8wAA + AAEAAAAAAAAAAAAAAAEAAADqAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAADkAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAFwAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAArAAAAAAAAAAAAAAAAAAA + ACYAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAJgAAAAAAAAAAAAAAAAAAAKwAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAAXAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdAAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAACIAAAAAAAAAAAAAAAAAAAAVgAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAABWAAAAAAAAAAAAAAAAAAAAiAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAB0AAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAACNAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAGsAAAAAAAAAAAAA + AAAAAACHAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAIcAAAAAAAAAAAAAAAAAAABrAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAI0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKYAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAATwAAAAAAAAAAAAAAAAAAALcAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAtwAAAAAAAAAAAAAAAAAAAE8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAApgAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAvgAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAzAAAAAAAA + AAAAAAAAAAAA5gAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADmAAAAAAAAAAAAAAAAAAAAMwAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAC+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADXAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAABYAAAAAAAAAAAAAAAAAAAD4AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAPgAAAAAAAAAAAAAAAAAAAAWAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + ANcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD5AAAAAQAA + AAAAAAAAAAAAAwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAAMAAAAAAAAAAAAA + AAEAAAD5AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAN4AAAAAAAAAAAAAAAAAAAANAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAADQAAAAAAAAAAAAAAAAAAAN4AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAACQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACEAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAxAAA + AAAAAAAAAAAAAAAAABgAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAYAAAAAAAA + AAAAAAAAAAAAxAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAhAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANAAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADEAAAAAAAAAAAAAAAAAAAAIwAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAACMAAAAAAAAAAAAAAAAAAADEAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAADQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AMkAAAAAAAAAAAAAAAAAAAAtAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAALQAA + AAAAAAAAAAAAAAAAAMkAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAALAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + ACEAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAzgAAAAAAAAAAAAAAAAAAADgAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAA4AAAAAAAAAAAAAAAAAAAAzgAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAAhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAADTAAAAAAAAAAAAAAAAAAAAQgAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AEIAAAAAAAAAAAAAAAAAAADTAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAABcAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAMAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAANgAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANgAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA3QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAA3QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAACAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAPcAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADiAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADiAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA9wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7AAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAOoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOoAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADsAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAADhAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAA0AAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAOEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANcAAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAANQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADUAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA1wAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAvwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAABdAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAXQAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAC/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACOAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAIYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACGAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AI4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFwAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAArwAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AK8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAXAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKgAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADXAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAAqAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAA9QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + APsAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAUAAAD7AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA9QAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAADHAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAADMAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAADHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJUAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAACFAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAJUAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAYwAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADYAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANgAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAYwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAxAAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAArAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAArAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAAxAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAYAAADzAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAH4AAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA8wAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJ0AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA0QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAA0QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAACdAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAOgAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAJAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACQAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAADoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAACKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAigAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADXAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB0AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + APgAAAAiAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACIAAAD4AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAHQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + ABcAAAD7AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAK4AAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAArgAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD7AAAAFwAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEEAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAK8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAATAAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA0QAAAAIAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAACAAAA0QAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAATAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAA5QAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAAZgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGYAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAOUAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAABlAAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAAZAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAADNAAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAMkAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADoAAAD+AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD+AAAANQAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AJwAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAJUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACQAAANEAAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAADNAAAABwAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAIwAAAPAAAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA7wAAACEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATQAAAP0AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP0AAABNAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAawAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA/wAA + AP8AAAD/AAAAZwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////gAAAAAAAAf///////////wAAAAAAAAD/// + ////////8AAAAAAAAA////////////AAAAAAAAAP///////////wAAAAAAAAD///////////8AAAAAAA + AA////////////AAAAAAAAAP///////////4AAAAAAAAH///////////////8A////////////////// + //AP///////////////////wD///////////////////8A////////////////////AP//////////// + ///////wD///////////////////8A////////////////////AP///////////////////wD/////// + ////////////8A////////////////////AP///////////////////wD///////////////////8A// + //////////////////AP///////////////////wD///////////////////8A////////////////// + //AP///////////////////wD///////////////////8A////////////////////AP//////////// + ///////wD///////////////////8A////////////////////AP///////////////////wD/////// + ////////////8A////////////////////AP///////////////////wD///////////////////8A// + //////////////////AP///////////////////wD///////////////////8A////////////////// + //AP///////////////////wD///////////////////8A////////////////////AP//////////// + ///////wD///////////////////8A////////////////////AP///////////////////wD/////// + ////////////8A///////////////////wAA//////////////////AAAA////////////////+AAAAB + ///////////////+AAAAAH//////////////+AAAAAAf/////////////+AAAAAAB/////////////+A + AAAAAAH/////////////AAAAAAAA/////////////gAAAAAAAH////////////wAAD/8AAA///////// + ///wAAP//8AAD///////////4AAP///wAAf//////////+AAP+AH/AAH///////////AAP4AAH8AA/// + ////////gAPwAAAPwAH//////////wAHwAAAA+AA//////////8ADwAAAADwAP/////////+AB4AAAAA + eAB//////////gA4AAAAABwAf/////////wAcAAAAAAOAD/////////8AOAAAAAABwA/////////+AHg + AAAAAAeAH/////////gBwAAAAAADgB/////////wA4AAAAAAAcAP////////8AOAAAAAAAHAD/////// + /+AHAAAAAAAA4Af////////gBwAAAAAAAOAH////////4AcAAAAAAADgB////////+AOAAAAAAAAcAf/ + ///////ADgAAAAAAAHAD////////wAwAAAAAAAAwA////////8AcAAAAAAAAOAP////////AHAAAAAAA + ADgD////////gBwAAAAAAAA4Af///////4AYAAAAAAAAGAH///////+AOAAAAAAAABwB////////gDgA + AAAAAAAcAf///////4A4AAAAAAAAHAH///////+AOAAAAAAAABwB////////gDgAAAAAAAAcAf////// + /4A4AAAAAAAAHAH///////+AMAAAAAAAAAwB////////AHAAAAAAAAAOAP///////wBwAAAAAAAADgD/ + //////8AcAAAAAAAAA4A////////AHAAAAAAAAAOAP///////wBwAAAAAAAADgD///////8AcAAAAAAA + AA4A////////AH/////////+AP///////wB//////////gD///////+Af/////////4B////////gH// + ///////+Af///////4A//////////AH///////+AP/////////wB////////gD/////////8Af////// + /4A//////////AH///////+AP/////////wB////////gD/////////8Af///////4Af////////+AH/ + ///////AH/////////gD////////wB/////////4A////////8Af////////+AP////////AD/////// + //AD////////wA/////////wA////////+AP////////8Af////////gB////////+AH////////8Af/ + ///////gD/////////AD////////wA/////////wA////////8AP////////+AH///////+AH/////// + //gA////////AB/////////4AP///////wAf/////////AAAAAAAAAAAP/////////wAAAAAAAAAAD// + ///////+AAAAAAAAAAB//////////wAAAAAAAAAA//////////8AAAAAAAAAAP//////////gAAAAAAA + AAH//////////8AAAAAAAAAD///////////gAAAAAAAAB/////+JUE5HDQoaCgAAAA1JSERSAAABAAAA + AQAIBAAAAPZ7YO0AAA6VSURBVHja7Z17jFXVFca/O4AMjAwvAdEKlpcSKqKgFRAbrMQ3KmoUtDFREluN + hrbGxNikokmrra8qxsS3tfgobTVVbOsjKgo44oiggMzIY8TKc3AEZ8B53f4hVNnn3rv2fZy1zt1r/Xai + udy77/3W2t/Ze5+zz+wDJIvZSAddOvEz6RQnmeloF2+iuEsrzpBOc1KZiBbx5uEoX2OCdKq/IyUt4P8M + wnIMlhbBxCYcjx3SIr6lQlrAPrrgOTXNDxyBZ5KS+S7SAvZxB2ZJS2BlGA7C69IiksMM8XGZv3TiPOm0 + A8mYA4zCMlRLixDgK5yAemkR8iNRT/xdZfMDvfEP9JQWIT8HuCMZXaEIA9ET/5GVID0EjMZKdBXWIEk7 + xmKNpADpIeA+1c0PdMV90hIk0Tj7j5YZkk0gOQRUYg2OlAw+IWzEaOyV+nHJSeDNOF/w15NDH7TjLakf + l+sBhmINeoj9erLYg9FokBbBzVMeo2MTDpeWWTRDsdsj0qekZXJzOFo90jJbWmZJuMYj0tYArJ4Xt3sk + 5VVpkSUihTc8or1dWiYnVdhJJuTrgM4QhqGZjHcnqqRl8nGtxxFxnbTIknK9R8TXSovkogJ1ZDJqxC9S + lzrmGjLmOvHrskxM9zgaTpcWWXJO94h6urRIHt4kE7FEWmIsLCHjflNaIgdjPI6EadIiY2GaR+RjpEXG + z61kEhZLS4yNxWTsc6Ulxs8aMgmnSUuMjdPI2FdJS4ybH5EpWCotMVaWkvGP5hXEfeJxMfmJh5kV8fII + +YmLpCXGy2rC/1+jl7TEWKkm//xthbTEOKHPAJ6Ulhg788kcjOSUwzsE0APAE6x6JKAjDHgQWEV4f0Ng + F4AzUYHPiSzUSkuMiyFk53eLtEQWfk/mYZC0xHiYRQZ+lLREFsaSeWAcBDjnAFOI9xuwllGNHCuxmfgE + lakSwmmAk4n3X2HUIgv152CMBuCjLzqT0/EJcymRiY4Q/1z2HCLodvSVlshGf3QQ2TiTSwrfEEB1a8vw + JZsWaRrJUz22QYDPADYD+D7/Jt4/RVpgqanEN0SnF1zIOTmFyMY3qJSWWFpOJALuDHwRyKUXOSU+kUcI + 1xBAXeKpx24mJclgN7k7ENNFMS4DjCLe/4BJR3KgIg7MAFQ4y5l0JAcqYuqQKRFJMYD1AC5BrYukyPtg + +klLZKcfkZGWkJbGqYXgjdICRdhIZGUIhwieIYDqzj5hUZE0qKhZBoFkGEDd9iheUQdkAOo2x89YVCQN + KmqWm0N5DHAo8b71AJlgeX4CjwEGFJmKMKGiPoRDBI8BqFBsCMgEddiUEZuJW0F07hfclXhGGnXnYBnR + ljNQnQMAADTkzEsbx6UgjiGgD3GENzJoSCa5I++K3vFL4DAANZbtYtCQTKjIGWYBHAagpoBmgGwEYgDr + AQqNnOFEkGcOUFwawoWKnOFGeQ4DUM8kMANkg6F1OAxA/YYZIBtKDXAP+dez5VruISIXaB0OA1CXMzqJ + 1+GQb6SBXAiifsOdI+gxADU7UtIDmAGyEYgBrAfIFhllABsCAkNpD0D52NWgxwBU9gPpAdqJ960HyAaV + uRLAYYCviPfdxeI2Bk0yuJFRN8JQmSsBSTCAux/OTgZNMriRUTsBBWKAJuJ9d81rB4MmGdwbQKjVvqb4 + JSWhB3DTEO4dQq61KQMo7QHCNYD1ABmxISAbgfQAu4kTO+sBMtPJsW0OhwHSxLJnNbod8HoP9jCo4mcP + Wg543Y04C9iFdPyieP4yqIl43z0StrOo4ibfKWAThygeA1B/4+JuhbCeRRU3blTUBhAsfxnEY4B1xPsj + nNdhbhvvRjWC+DyVtZLAYwDqiB7uvK5jUcWNG9Vw4vMBGcB6gExRKeoB8jVAmD2AGSArbme4IcAVwTZs + IKJ2YZkK8xhgi3MG7DLQ2Sq6PcDzgPXO6n4vDMz5+WZs4ZDFtVMo1aDuhkjhbRznDgDUFlBMhwCXAahB + YLzz+j0mXXzUOK+PJz7PMgPgM8CnxPsnOa/fYdLFhxvRj4nPUxkrEVwGoLZGdg3wHlqZlPHQGunTKAMw + PUCWywBUlz7a2Q5lb2BP0K3F3gNeH4wxRI1lPML4hoDcd/qlIo9ICWsQcKOZQGS+MbQ5AO3osGcB+c4A + mI5/TgNQg4BrgMUcq+FMpLHY+RfKAGxnQckxwCTnzyQa8TGbtrj52LkXqAKTiRoB9gBUSH0iSXmeTVvc + uJGcQFwFDLIH2EruCHq283oBm7a4cSM5l/h8A7ZxSeN8fDzlatcAHweyLLw2MphRBmC8DsppgDeI98fg + SOdf/saoLj7cKIZgLFGDylQJ4TTAQvITbh8QpgGo498nU2XKCmIXrZcjNerFd/YqtkQfEfsvosYKzibh + 7AGAl4j3p0bulC//iaAbQW9MJWpQWSpjJpLHy9VOjSHEswaSXtoiN3//gqwzUbqZ4qMC24ngo1cLnhFv + xGLKM5F4aoka25l7ZWaeJFN2rFNjgngjFlMmONGMI2s8ydsg3G6jx7fZzuv3sYhZY+lYhPeJ6KIEPAMA + gGq0EkfATlQ6dc4VP44LLe4JXyW+JGq0ktvGlD3Pk2m7zKmRwifiTVlI+SSyzdtlZJ1w1j+ychaZhJpI + nVnijVlImRWJ412yzlnSzRM/FfiMTEP0Wtlr4s2Zb3ktEsPZZJ3Pwj4D2M8tZCKWRzrPUdgr3qT5lL0Y + FYmbOgFM4xbppuFhCDrIVFwUqTVXvFHzKXMj+s8n63SQOwYEw8tkMlZHOsPuZbQuUI/ujvoUuQ6SaSUk + WC7wSOLlkVrTxBvWt0yLaL/Yo9YF0s3CR1dsIdOxHj0j9W4Tb1qfcltEdyXWkrW26HqEts+I/qdIrRSe + Fm9eqjydYZP3P3rUm+uTtnDojZ1kSjoxJVKvO94Wb+Jc5e3I6A9M9Jj07uR4UHSyuMkjnfXoEanXH3Xi + zZyt1KF/RG+l13XMm6Sbg58qbPVIzN0Zao7wmEFIlC0ZN32506PmVlRJN4cEczxS04GTM9Q8EqvEm9st + qyK3tALAZI/uP4050k0hQyU2eR1XwzLU7ZOwi8OvZXxE9jCvvmpTZP1TDVd7JXdthpEV6IbHxJt9f3nM + 2e34W/p7nPylEb0NThHdsM4rRUsyTAYB4Ao0iTd+E67IqK0HlnjVX5fRPGo4xzPNL2RZJzsCr4o2/yv4 + QUZdFXjB8xvOkW4CaXw78kezPGQthWuwW6Txd+HnWWLqgkc9v+Nx6fTLU40Gz2S9lPVkaRDmkbealba0 + 4n4MyKKmCi95fssmfZd/MvFTdHomrBaDs37LMMz3/p7iSieezbHL52CPdf/95XTp1CeFed4pa8i5udJY + PILmWBu/BY/juBwKxnj3Z2k8JJ325FCFT73T1oTzcn5XH/zS8/Qr31KHX6Ffzt8+P4+zko3O9rjK8bti + tr88TFw6TWEKbsXiEs0L2rAYt2IK8SjnXt4Tv2+HkVOlU540rs+rUerJbZYA4GCciTvxQV7m+n4jLcdd + OAsHe/zSZKzP67tvlE53EvFZNf/+cflb71so+uFCPICVaPSYJnaiER/hQVxEPtRpPwfhd3la7E+e38wA + wxPq89Dylwz30udiLW7EP/Oq0RWHYAAGYAAG7vsvsB3bsB3b9/1/R54Pbb8Qf8i4WpGdv2Im8SRFtRyE + 1/PuqN+I7DTOx3i8VYDe7sX/cLhU48MCxuo/Z1yKjZcj8EQB1x1W2qUfisPyOJP+rrRjASaxaZyE5wra + uKIBh0mntxwYitUFzdrTqMHMmO+t7YbLsaxAdasxVDq15UJfLCowyWl8jrsxOYbJbQVOwf1F3Iy2CH2l + 01pOVGJBwalOI40vMA9Ts6wf5ksFfoJ52FyUngV67/oplArcW1TK00jjS7yM3+DUAm+5rMJU3IyFaCxa + x73J/avfJF0HiHIt7irJSVMHVuIDfIp1WId1+CrHJ6sxHCMwHCNwHI4tSQ/yDX6NB5jyVQDJNgBwPJ4l + H7CWLzuwBc1oRjNa0AygCj1RhSr0xKFZ1/gLpR6Xks9LMnLSC/OL7oKlynxb8SsNV8a8yh9HacaV0mkL + iaMLuOgqWd7C0dIpC48rsE28YX3Ktiw3ihtF0w8PMd33V2jpxEPEXUNGkUwq+FJs/GUZ43qEaqZjuXhj + u2U5pkunRRMpzMBH4o2+v3yEGYm/ohIgKVyCGvHGr8El1viSjMOD2CXS9LvwIMZJh28AQBVmM08Nl2G2 + zl09kswI3IB3CrwF3Ld04B3ckHErGCMhDMRVeBEtJW/6FryIq8hHvpYdoU5fuuFYTMRJmIgfFvlNG7AU + 72IpVqBNOqg4CNUA3zEIJ+IojMRIjMThXvGm8V/Uox71WIv3sFU6AKN09MAxZFd/TJbtaAIl/B7AJU28 + rywjib1XzeDBDKAcM4ByzADKMQMoxwygHDOAcswAyjEDKMcMoBwzgHLMAMoxAyjHDKAcM4ByzADKMQMo + xwygHDOAcswAyjEDKMcMoBwzgHLMAMoxAyjHDKAcM4ByzADKMQMoxwygHDOAcswAyjEDKMcMoBwzgHLM + AMoxAyjHDKAcM4ByzADKMQMoxwygHDOAcswAyjEDKMcMoBwzgHLMAMoxAyjHDKAcM4ByzADKMQMoxwyg + HDOAcswAyjEDKMcMoBwzgHLMAMoxAyjHDKAcM4ByzADKMQMoxwygHDOAcswAyjEDKMcMoBwzgHLMAMox + AyjHDKAcM4ByzADKMQMoxwygHDOAclLSArIyCrMwHuMxWFpIkWxGLWrxNOqkhZQTKcxBC9IBlRbMSfDB + ljBSWCjeYHGUhUm0QBdpARmYg+ukJcTCSDThXWkRLsnz5Ch8iB7SImJiD8YlbS6QvLOAmcE2P9ADM6Ul + uCTPABOkBeiKLnlDwBdlf+KXi804TFrCgSSvBzBYSZ4BaqUF6IoueQZ4X1qAruiSNwew00BWknchqBHN + OENaREzciIXSEsoBuxSsHlsMYkx1UrHlYBb+BzExSINjzasWAAAAAElFTkSuQmCC + + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/GridEntry.cs b/LibationWinForm/UNTESTED/GridEntry.cs new file mode 100644 index 00000000..3cedfccf --- /dev/null +++ b/LibationWinForm/UNTESTED/GridEntry.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Linq; +using DataLayer; + +namespace LibationWinForm +{ + internal class GridEntry + { + private LibraryBook libraryBook; + private Book book => libraryBook.Book; + + public Book GetBook() => book; + + // this special case is obvious and ugly + public void REPLACE_Library_Book(LibraryBook libraryBook) => this.libraryBook = libraryBook; + + public GridEntry(LibraryBook libraryBook) => this.libraryBook = libraryBook; + + // hide from public fields from Data Source GUI with [Browsable(false)] + + [Browsable(false)] + public string Tags => book.UserDefinedItem.Tags; + [Browsable(false)] + public IEnumerable TagsEnumerated => book.UserDefinedItem.TagsEnumerated; + + private Dictionary formatReplacements { get; } = new Dictionary(); + public bool TryGetFormatted(string key, out string value) => formatReplacements.TryGetValue(key, out value); + + public Image Cover => + Dinah.Core.Drawing.ImageConverter.GetPictureFromBytes( + FileManager.PictureStorage.GetImage(book.PictureId, FileManager.PictureStorage.PictureSize._80x80) + ); + + public string Title + { + get + { + formatReplacements[nameof(Title)] = book.Title; + + var sortName = book.Title + .Replace("|", "") + .Replace(":", "") + .ToLowerInvariant(); + if (sortName.StartsWith("the ") || sortName.StartsWith("a ") || sortName.StartsWith("an ")) + sortName = sortName.Substring(sortName.IndexOf(" ") + 1); + + return sortName; + } + } + + public string Authors => book.AuthorNames; + public string Narrators => book.NarratorNames; + + public int Length + { + get + { + formatReplacements[nameof(Length)] + = book.LengthInMinutes == 0 + ? "[pre-release]" + : $"{book.LengthInMinutes / 60} hr {book.LengthInMinutes % 60} min"; + + return book.LengthInMinutes; + } + } + + public string Series => book.SeriesNames; + + public string Description + => book.Description == null ? "" + : book.Description.Length < 63 ? book.Description + : book.Description.Substring(0, 60) + "..."; + + public string Category => string.Join(" > ", book.CategoriesNames); + + // star ratings retain numeric value but display star text. this is needed because just using star text doesn't sort correctly: + // - star + // - star star + // - star 1/2 + + public string Product_Rating + { + get + { + Rating rating = book.Rating; + + formatReplacements[nameof(Product_Rating)] = starString(rating); + + return firstScore(rating); + } + } + + public DateTime? Purchase_Date => libraryBook.DateAdded; + + public string My_Rating + { + get + { + Rating rating = book.UserDefinedItem.Rating; + + formatReplacements[nameof(My_Rating)] = starString(rating); + + return firstScore(rating); + } + } + + private string starString(Rating rating) + => (rating?.FirstScore != null && rating?.FirstScore > 0f) + ? rating?.ToStarString() + : ""; + private string firstScore(Rating rating) => rating?.FirstScore.ToString("0.0"); + + // max 5 text rows + public string Misc + { + get + { + var details = new List(); + if (book.HasPdfs) + details.Add("Has PDFs"); + if (book.IsAbridged) + details.Add("Abridged"); + if (book.DatePublished.HasValue) + details.Add($"Date pub'd: {book.DatePublished.Value.ToString("MM/dd/yyyy")}"); + // this goes last since it's most likely to have a line-break + if (!string.IsNullOrWhiteSpace(book.Publisher)) + details.Add($"Pub: {book.Publisher}"); + + if (!details.Any()) + return "[details not imported]"; + + return string.Join("\r\n", details); + } + } + + public string Download_Status + { + get + { + var print + = FileManager.AudibleFileStorage.Audio.ExistsAsync(book.AudibleProductId).GetAwaiter().GetResult() ? "Liberated" + : FileManager.AudibleFileStorage.AAX.ExistsAsync(book.AudibleProductId).GetAwaiter().GetResult() ? "DRM" + : "NOT d/l'ed"; + + if (!book.Supplements.Any()) + return print; + + print += "\r\n"; + + var downloadStatuses = book.Supplements + .Select(d => FileManager.AudibleFileStorage.PDF.ExistsAsync(book.AudibleProductId).GetAwaiter().GetResult()) + // break delayed execution right now! + .ToList(); + var count = downloadStatuses.Count; + if (count == 1) + { + print += downloadStatuses[0] + ? "PDF d/l'ed" + : "PDF NOT d/l'ed"; + } + else + { + var downloadedCount = downloadStatuses.Count(s => s); + print + += downloadedCount == count ? $"{count} PDFs d/l'ed" + : downloadedCount == 0 ? $"{count} PDFs NOT d/l'ed" + : $"{downloadedCount} of {count} PDFs d/l'ed"; + } + + return print; + } + } + } +} diff --git a/LibationWinForm/UNTESTED/ProductsGrid.Designer.cs b/LibationWinForm/UNTESTED/ProductsGrid.Designer.cs new file mode 100644 index 00000000..9dccbb64 --- /dev/null +++ b/LibationWinForm/UNTESTED/ProductsGrid.Designer.cs @@ -0,0 +1,201 @@ +namespace LibationWinForm +{ + partial class ProductsGrid + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.components = new System.ComponentModel.Container(); + this.gridEntryBindingSource = new System.Windows.Forms.BindingSource(this.components); + this.gridEntryDataGridView = new System.Windows.Forms.DataGridView(); + this.dataGridViewImageColumn1 = new System.Windows.Forms.DataGridViewImageColumn(); + this.dataGridViewTextBoxColumn1 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn2 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn3 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn4 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn5 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn6 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn7 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn8 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn9 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn10 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn11 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.dataGridViewTextBoxColumn12 = new System.Windows.Forms.DataGridViewTextBoxColumn(); + ((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).BeginInit(); + this.SuspendLayout(); + // + // gridEntryBindingSource + // + this.gridEntryBindingSource.DataSource = typeof(LibationWinForm.GridEntry); + // + // gridEntryDataGridView + // + this.gridEntryDataGridView.AutoGenerateColumns = false; + this.gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { + this.dataGridViewImageColumn1, + this.dataGridViewTextBoxColumn1, + this.dataGridViewTextBoxColumn2, + this.dataGridViewTextBoxColumn3, + this.dataGridViewTextBoxColumn4, + this.dataGridViewTextBoxColumn5, + this.dataGridViewTextBoxColumn6, + this.dataGridViewTextBoxColumn7, + this.dataGridViewTextBoxColumn8, + this.dataGridViewTextBoxColumn9, + this.dataGridViewTextBoxColumn10, + this.dataGridViewTextBoxColumn11, + this.dataGridViewTextBoxColumn12}); + this.gridEntryDataGridView.DataSource = this.gridEntryBindingSource; + this.gridEntryDataGridView.Location = new System.Drawing.Point(54, 58); + this.gridEntryDataGridView.Name = "gridEntryDataGridView"; + this.gridEntryDataGridView.Size = new System.Drawing.Size(300, 220); + this.gridEntryDataGridView.TabIndex = 0; + // + // dataGridViewImageColumn1 + // + this.dataGridViewImageColumn1.DataPropertyName = "Cover"; + this.dataGridViewImageColumn1.HeaderText = "Cover"; + this.dataGridViewImageColumn1.Name = "dataGridViewImageColumn1"; + this.dataGridViewImageColumn1.ReadOnly = true; + // + // dataGridViewTextBoxColumn1 + // + this.dataGridViewTextBoxColumn1.DataPropertyName = "Title"; + this.dataGridViewTextBoxColumn1.HeaderText = "Title"; + this.dataGridViewTextBoxColumn1.Name = "dataGridViewTextBoxColumn1"; + this.dataGridViewTextBoxColumn1.ReadOnly = true; + // + // dataGridViewTextBoxColumn2 + // + this.dataGridViewTextBoxColumn2.DataPropertyName = "Authors"; + this.dataGridViewTextBoxColumn2.HeaderText = "Authors"; + this.dataGridViewTextBoxColumn2.Name = "dataGridViewTextBoxColumn2"; + this.dataGridViewTextBoxColumn2.ReadOnly = true; + // + // dataGridViewTextBoxColumn3 + // + this.dataGridViewTextBoxColumn3.DataPropertyName = "Narrators"; + this.dataGridViewTextBoxColumn3.HeaderText = "Narrators"; + this.dataGridViewTextBoxColumn3.Name = "dataGridViewTextBoxColumn3"; + this.dataGridViewTextBoxColumn3.ReadOnly = true; + // + // dataGridViewTextBoxColumn4 + // + this.dataGridViewTextBoxColumn4.DataPropertyName = "Length"; + this.dataGridViewTextBoxColumn4.HeaderText = "Length"; + this.dataGridViewTextBoxColumn4.Name = "dataGridViewTextBoxColumn4"; + this.dataGridViewTextBoxColumn4.ReadOnly = true; + // + // dataGridViewTextBoxColumn5 + // + this.dataGridViewTextBoxColumn5.DataPropertyName = "Series"; + this.dataGridViewTextBoxColumn5.HeaderText = "Series"; + this.dataGridViewTextBoxColumn5.Name = "dataGridViewTextBoxColumn5"; + this.dataGridViewTextBoxColumn5.ReadOnly = true; + // + // dataGridViewTextBoxColumn6 + // + this.dataGridViewTextBoxColumn6.DataPropertyName = "Description"; + this.dataGridViewTextBoxColumn6.HeaderText = "Description"; + this.dataGridViewTextBoxColumn6.Name = "dataGridViewTextBoxColumn6"; + this.dataGridViewTextBoxColumn6.ReadOnly = true; + // + // dataGridViewTextBoxColumn7 + // + this.dataGridViewTextBoxColumn7.DataPropertyName = "Category"; + this.dataGridViewTextBoxColumn7.HeaderText = "Category"; + this.dataGridViewTextBoxColumn7.Name = "dataGridViewTextBoxColumn7"; + this.dataGridViewTextBoxColumn7.ReadOnly = true; + // + // dataGridViewTextBoxColumn8 + // + this.dataGridViewTextBoxColumn8.DataPropertyName = "Product_Rating"; + this.dataGridViewTextBoxColumn8.HeaderText = "Product_Rating"; + this.dataGridViewTextBoxColumn8.Name = "dataGridViewTextBoxColumn8"; + this.dataGridViewTextBoxColumn8.ReadOnly = true; + // + // dataGridViewTextBoxColumn9 + // + this.dataGridViewTextBoxColumn9.DataPropertyName = "Purchase_Date"; + this.dataGridViewTextBoxColumn9.HeaderText = "Purchase_Date"; + this.dataGridViewTextBoxColumn9.Name = "dataGridViewTextBoxColumn9"; + this.dataGridViewTextBoxColumn9.ReadOnly = true; + // + // dataGridViewTextBoxColumn10 + // + this.dataGridViewTextBoxColumn10.DataPropertyName = "My_Rating"; + this.dataGridViewTextBoxColumn10.HeaderText = "My_Rating"; + this.dataGridViewTextBoxColumn10.Name = "dataGridViewTextBoxColumn10"; + this.dataGridViewTextBoxColumn10.ReadOnly = true; + // + // dataGridViewTextBoxColumn11 + // + this.dataGridViewTextBoxColumn11.DataPropertyName = "Misc"; + this.dataGridViewTextBoxColumn11.HeaderText = "Misc"; + this.dataGridViewTextBoxColumn11.Name = "dataGridViewTextBoxColumn11"; + this.dataGridViewTextBoxColumn11.ReadOnly = true; + // + // dataGridViewTextBoxColumn12 + // + this.dataGridViewTextBoxColumn12.DataPropertyName = "Download_Status"; + this.dataGridViewTextBoxColumn12.HeaderText = "Download_Status"; + this.dataGridViewTextBoxColumn12.Name = "dataGridViewTextBoxColumn12"; + this.dataGridViewTextBoxColumn12.ReadOnly = true; + // + // ProductsGrid + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.Controls.Add(this.gridEntryDataGridView); + this.Name = "ProductsGrid"; + this.Size = new System.Drawing.Size(434, 329); + ((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).EndInit(); + this.ResumeLayout(false); + + } + + #endregion + + private System.Windows.Forms.BindingSource gridEntryBindingSource; + private System.Windows.Forms.DataGridView gridEntryDataGridView; + private System.Windows.Forms.DataGridViewImageColumn dataGridViewImageColumn1; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn1; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn2; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn3; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn4; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn5; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn6; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn7; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn8; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn9; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn10; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn11; + private System.Windows.Forms.DataGridViewTextBoxColumn dataGridViewTextBoxColumn12; + } +} diff --git a/LibationWinForm/UNTESTED/ProductsGrid.cs b/LibationWinForm/UNTESTED/ProductsGrid.cs new file mode 100644 index 00000000..8970618e --- /dev/null +++ b/LibationWinForm/UNTESTED/ProductsGrid.cs @@ -0,0 +1,271 @@ +using System; +using System.Drawing; +using System.Linq; +using System.Windows.Forms; +using Dinah.Core.DataBinding; +using DataLayer; + +namespace LibationWinForm +{ + // INSTRUCTIONS TO UPDATE DATA_GRID_VIEW + // - delete current DataGridView + // - view > other windows > data sources + // - refresh + // OR + // - Add New Data Source + // Object. Next + // LibationWinForm + // AudibleDTO + // GridEntry + // - go to Design view + // - click on Data Sources > ProductItem. drowdown: DataGridView + // - drag/drop ProductItem on design surface + public partial class ProductsGrid : UserControl + { + private DataGridView dataGridView; + + private Form1 parent; + + // this is a simple ctor for loading library and wish list. can expand later for other options. eg: overload ctor + public ProductsGrid(Form1 parent) : this() => this.parent = parent; + public ProductsGrid() => InitializeComponent(); + + private bool hasBeenDisplayed = false; + public void Display() + { + if (hasBeenDisplayed) + return; + hasBeenDisplayed = true; + + dataGridView = gridEntryDataGridView; + + dataGridView.Dock = DockStyle.Fill; + dataGridView.AllowUserToAddRows = false; + dataGridView.AllowUserToDeleteRows = false; + dataGridView.AutoGenerateColumns = false; + dataGridView.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize; + dataGridView.DefaultCellStyle.WrapMode = DataGridViewTriState.True; + dataGridView.ReadOnly = true; + dataGridView.RowHeadersVisible = false; + // adjust height for 80x80 pictures. + // this must be done before databinding. or can alter later by iterating through rows + dataGridView.RowTemplate.Height = 82; + dataGridView.CellFormatting += replaceFormatted; + dataGridView.CellFormatting += hiddenFormatting; + // sorting breaks filters. must reapply filters after sorting + dataGridView.Sorted += (_, __) => Filter(); + + { // add tag buttons + var editUserTagsButton = new DataGridViewButtonColumn { HeaderText = "Edit Tags" }; + dataGridView.Columns.Add(editUserTagsButton); + + // add image and handle click + dataGridView.CellPainting += paintEditTag_TextAndImage; + dataGridView.CellContentClick += dataGridView_GridButtonClick; + } + + for (var i = dataGridView.ColumnCount - 1; i >= 0; i--) + { + DataGridViewColumn col = dataGridView.Columns[i]; + + // initial HeaderText is the lookup name from GridEntry class. any formatting below won't change this + col.Name = col.HeaderText; + + if (!(col is DataGridViewImageColumn || col is DataGridViewButtonColumn)) + col.SortMode = DataGridViewColumnSortMode.Automatic; + + col.HeaderText = col.HeaderText.Replace("_", " "); + + if (col.Name == nameof(GridEntry.Title)) + col.Width *= 2; + + if (col.Name == nameof(GridEntry.Misc)) + col.Width = (int)(col.Width * 1.35); + } + + + // + // transform into sorted GridEntry.s BEFORE binding + // + var lib = LibraryQueries.GetLibrary_Flat_NoTracking(); + var orderedGridEntries = lib + .Select(lb => new GridEntry(lb)).ToList() + // default load order: sort by author, then series, then title + .OrderBy(ge => ge.Authors) + .ThenBy(ge => ge.Series) + .ThenBy(ge => ge.Title) + .ToList(); + + // + // BIND + // + gridEntryBindingSource.DataSource = orderedGridEntries.ToSortableBindingList(); + + // + // AFTER BINDING, BEFORE FILTERING + // + // now that we have data, remove/hide text columns with blank data. don't search image and button columns. + // simplifies the interface in general. also distinuishes library from wish list etc w/o explicit filters. + // must be AFTER BINDING, BEFORE FILTERING because we don't want to remove rows when valid data is simply not visible due to filtering. + for (var c = dataGridView.ColumnCount - 1; c >= 0; c--) + { + if (!(dataGridView.Columns[c] is DataGridViewTextBoxColumn textCol)) + continue; + + bool hasData = false; + for (var r = 0; r < dataGridView.RowCount; r++) + { + var value = dataGridView[c, r].Value; + if (value != null && value.ToString() != "") + { + hasData = true; + break; + } + } + + if (!hasData) + dataGridView.Columns.Remove(textCol); + } + + // + // FILTER + // + Filter(); + } + + private void paintEditTag_TextAndImage(object sender, DataGridViewCellPaintingEventArgs e) + { + // DataGridView Image for Button Column: https://stackoverflow.com/a/36253883 + + if (e.RowIndex < 0 || !(((DataGridView)sender).Columns[e.ColumnIndex] is DataGridViewButtonColumn)) + return; + + + var gridEntry = getGridEntry(e.RowIndex); + var displayTags = gridEntry.TagsEnumerated.ToList(); + + if (displayTags.Any()) + dataGridView.Rows[e.RowIndex].Cells[e.ColumnIndex].Value = string.Join("\r\n", displayTags); + else // no tags: use image + { + // clear tag text + dataGridView.Rows[e.RowIndex].Cells[e.ColumnIndex].Value = ""; + + // images from: icons8.com -- search: tags + var image = Properties.Resources.edit_tags_25x25; + + e.Paint(e.CellBounds, DataGridViewPaintParts.All); + + var w = image.Width; + var h = image.Height; + var x = e.CellBounds.Left + (e.CellBounds.Width - w) / 2; + var y = e.CellBounds.Top + (e.CellBounds.Height - h) / 2; + + e.Graphics.DrawImage(image, new Rectangle(x, y, w, h)); + e.Handled = true; + } + } + + private void dataGridView_GridButtonClick(object sender, DataGridViewCellEventArgs e) + { + // handle grid button click: https://stackoverflow.com/a/13687844 + + if (e.RowIndex < 0) + return; + if (sender != dataGridView) + throw new Exception($"{nameof(dataGridView_GridButtonClick)} has incorrect sender ...somehow"); + if (!(dataGridView.Columns[e.ColumnIndex] is DataGridViewButtonColumn)) + return; + + var liveGridEntry = getGridEntry(e.RowIndex); + + // EditTagsDialog should display better-formatted title + liveGridEntry.TryGetFormatted(nameof(liveGridEntry.Title), out string value); + + var editTagsForm = new EditTagsDialog(value, liveGridEntry.Tags); + if (editTagsForm.ShowDialog() != DialogResult.OK) + return; + + var qtyChanges = saveChangedTags(liveGridEntry.GetBook(), editTagsForm.NewTags); + if (qtyChanges == 0) + return; + + // force a re-draw, and re-apply filters + + // needed to update text colors + dataGridView.InvalidateRow(e.RowIndex); + + Filter(); + } + + private static int saveChangedTags(Book book, string newTags) + { + book.UserDefinedItem.Tags = newTags; + + var qtyChanges = DomainServices.Indexer.IndexChangedTags(book); + return qtyChanges; + } + + #region Cell Formatting + private void replaceFormatted(object sender, DataGridViewCellFormattingEventArgs e) + { + var col = ((DataGridView)sender).Columns[e.ColumnIndex]; + if (col is DataGridViewTextBoxColumn textCol && getGridEntry(e.RowIndex).TryGetFormatted(textCol.Name, out string value)) + e.Value = value; + } + + private void hiddenFormatting(object sender, DataGridViewCellFormattingEventArgs e) + { + var isHidden = getGridEntry(e.RowIndex).TagsEnumerated.Contains("hidden"); + + dataGridView.Rows[e.RowIndex].Cells[e.ColumnIndex].Style + = isHidden + ? new DataGridViewCellStyle { ForeColor = Color.LightGray } + : dataGridView.DefaultCellStyle; + } + #endregion + + public void UpdateRow(string productId) + { + for (var r = dataGridView.RowCount - 1; r >= 0; r--) + { + var gridEntry = getGridEntry(r); + if (gridEntry.GetBook().AudibleProductId == productId) + { + var libBook = LibraryQueries.GetLibraryBook_Flat_NoTracking(productId); + gridEntry.REPLACE_Library_Book(libBook); + dataGridView.InvalidateRow(r); + + return; + } + } + } + + #region filter + string _filterSearchString; + public void Filter() => Filter(_filterSearchString); + public void Filter(string searchString) + { + _filterSearchString = searchString; + + var searchResults = new LibationSearchEngine.SearchEngine().Search(searchString); + var productIds = searchResults.Docs.Select(d => d.ProductId).ToList(); + + // https://stackoverflow.com/a/18942430 + var currencyManager = (CurrencyManager)BindingContext[dataGridView.DataSource]; + currencyManager.SuspendBinding(); + { + for (var r = dataGridView.RowCount - 1; r >= 0; r--) + dataGridView.Rows[r].Visible = productIds.Contains(getGridEntry(r).GetBook().AudibleProductId); + } + currencyManager.ResumeBinding(); + + + // after applying filters, display new visible count + parent.SetVisibleCount(dataGridView.Rows.Cast().Count(r => r.Visible), searchResults.SearchString); + } + #endregion + + private GridEntry getGridEntry(int rowIndex) => (GridEntry)dataGridView.Rows[rowIndex].DataBoundItem; + } +} diff --git a/LibationWinForm/UNTESTED/ProductsGrid.resx b/LibationWinForm/UNTESTED/ProductsGrid.resx new file mode 100644 index 00000000..d1166daf --- /dev/null +++ b/LibationWinForm/UNTESTED/ProductsGrid.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + \ No newline at end of file diff --git a/LibationWinForm/UNTESTED/Program.cs b/LibationWinForm/UNTESTED/Program.cs new file mode 100644 index 00000000..9b8e88f2 --- /dev/null +++ b/LibationWinForm/UNTESTED/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace LibationWinForm +{ + static class Program + { + /// + /// The main entry point for the application. + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/LibationWinForm/libation.ico b/LibationWinForm/libation.ico new file mode 100644 index 00000000..5fb77135 Binary files /dev/null and b/LibationWinForm/libation.ico differ diff --git a/REFERENCE.txt b/REFERENCE.txt new file mode 100644 index 00000000..7c50482c --- /dev/null +++ b/REFERENCE.txt @@ -0,0 +1,154 @@ +-- begin AUDIBLE DETAILS --------------------------------------------------------------------------------------------------------------------- + +alternate book id (eg BK_RAND_006061) is called 'sku' , 'sku_lite' , 'prod_id' , 'product_id' in different parts of the site + +-- end AUDIBLE DETAILS --------------------------------------------------------------------------------------------------------------------- + +-- begin DOTNET CORE DI --------------------------------------------------------------------------------------------------------------------- +from: https://andrewlock.net/using-dependency-injection-in-a-net-core-console-application/ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +public class Program +{ + public static void Main(string[] args) + { + //setup our DI + var serviceProvider = new ServiceCollection() + .AddLogging() + .AddSingleton() + .AddSingleton() + .BuildServiceProvider(); + + //configure console logging + serviceProvider + .GetService() + .AddConsole(LogLevel.Debug); + + var logger = serviceProvider.GetService().CreateLogger(); + logger.LogDebug("Starting application"); + + //do the actual work here + var bar = serviceProvider.GetService(); + bar.DoSomeRealWork(); + + logger.LogDebug("All done!"); + } +} +-- end DOTNET CORE DI --------------------------------------------------------------------------------------------------------------------- + +-- begin SOLUTION LAYOUT --------------------------------------------------------------------------------------------------------------------- +core libraries + extend Standard Libraries + additional simple libraries for general purpose programming +utils: domain ignorant +domain: biz logic + db ignorant + db, domain objects + db aware + incl non-db persistence. unless it needs to be tied into a db intercepter + all user-provided data must be backed up to json + utilities: domain aware +application + +do NOT combine jsons for +- audible-scraped persistence: library, book details +- libation-generated persistence: FilePaths.json +- user-defined persistence: BookTags.json +-- end SOLUTION LAYOUT --------------------------------------------------------------------------------------------------------------------- + +-- begin EF CORE --------------------------------------------------------------------------------------------------------------------- +transaction notes +----------------- +// https://msdn.microsoft.com/en-us/data/dn456843.aspx +// Rollback is called by transaction Dispose(). No need to call it explicitly + using (var dbContext = new LibationContext()) + using (var dbContextTransaction = dbContext.Database.BeginTransaction()) + { + refreshAction(dbContext, productItems); + dbContext.SaveChanges(); + dbContextTransaction.Commit(); + } + +aggregate root is transactional boundary + // //context.Database.CurrentTransaction + //var dbTransaction = Microsoft.EntityFrameworkCore.Storage.DbContextTransactionExtensions.GetDbTransaction(context.Database.CurrentTransaction); + // // test with and without : using (TransactionScope scope = new TransactionScope()) + //System.Transactions.Transaction.Current.TransactionCompleted += (sender, e) => { }; + // also : https://docs.microsoft.com/en-us/dotnet/api/system.transactions.transaction.enlistvolatile + +pattern when using 1 db context per form + public Ctor() + { + InitializeComponent(); + // dispose context here only. DO NOT dispose in OnParentChanged(). parent form will call dispose after this one has been switched. + // disposing context prematurely can result in: + // The ObjectContext instance has been disposed and can no longer be used for operations that require a connection. + this.Disposed += (_, __) => context?.Dispose(); + } +-- end EF CORE --------------------------------------------------------------------------------------------------------------------- + +-- begin ASYNC/AWAIT --------------------------------------------------------------------------------------------------------------------- +Using Async and Await to update the UI Thread Stephen Haunts { Coding in the Trenches } +https://stephenhaunts.com/2014/10/14/using-async-and-await-to-update-the-ui-thread/ + +Using Async and Await to update the UI Thread Part 2 Stephen Haunts { Coding in the Trenches } +https://stephenhaunts.com/2014/10/16/using-async-and-await-to-update-the-ui-thread-part-2/ + +Simple Async Await Example for Asynchronous Programming Stephen Haunts { Coding in the Trenches } +https://stephenhaunts.com/2014/10/10/simple-async-await-example-for-asynchronous-programming/ + +Async and Await -- Stephen Cleary's famous intro +https://blog.stephencleary.com/2012/02/async-and-await.html + +Async-Await - Best Practices in Asynchronous Programming -- Stephen Cleary +https://msdn.microsoft.com/en-us/magazine/jj991977.aspx + + +ASYNC PARALLELISM -- pre C# 8 +================= +private async Task WaitThreeSeconds(int param) +{ + Console.WriteLine($"{param} started ------ ({DateTime.Now:hh:mm:ss}) ---"); + await Task.Delay(3000); + Console.WriteLine($"{ param} finished ------({ DateTime.Now:hh:mm: ss}) ---"); +} +/////////////////////////////////////////////////////////////////////// +// SERIAL CODE +var listOfInts = new List() { 1, 2, 3 }; +foreach (var integer in listOfInts) + await WaitThreeSeconds(integer); +// SERIAL RESULTS +1 started ------ (00:00:40) --- +1 finished ------(00:00: 43) --- +2 started ------ (00:00:43) --- +2 finished ------(00:00: 46) --- +3 started ------ (00:00:46) --- +2 finished ------(00:00: 49) --- +/////////////////////////////////////////////////////////////////////// +// PARALLEL CODE +var listOfInts = new List() { 1, 2, 3 }; +var tasks = new List(); +foreach (var integer in listOfInts) + tasks.Add(WaitThreeSeconds(integer)); +await Task.WhenAll(tasks); +// PARALLEL RESULTS +1 started ------ (00:00:02) --- +2 started ------ (00:00:02) --- +3 started ------ (00:00:02) --- +3 finished ------(00:00: 05) --- +2 finished ------(00:00: 05) --- +1 finished ------(00:00: 05) --- +/////////////////////////////////////////////////////////////////////// +// similar. note that await IS NOT being used in the top method calls. TASKS are returned. those tasks are later awaited in parallel +var catTask = FeedCatAsync(); +var houseTask = SellHouseAsync(); +var carTask = BuyCarAsync(); + +await Task.WhenAll(catTask, houseTask, carTask).ConfigureAwait(false); + +var cat = await catTask; +var house = await houseTask; +var car = await carTask; +-- end ASYNC/AWAIT --------------------------------------------------------------------------------------------------------------------- diff --git a/Scraping/Scraping.csproj b/Scraping/Scraping.csproj new file mode 100644 index 00000000..49e55777 --- /dev/null +++ b/Scraping/Scraping.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.1 + + + + + + + diff --git a/Scraping/UNTESTED/AudibleScraper.cs b/Scraping/UNTESTED/AudibleScraper.cs new file mode 100644 index 00000000..a94343b5 --- /dev/null +++ b/Scraping/UNTESTED/AudibleScraper.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AudibleDotCom; +using Dinah.Core; +using Scraping.BookDetail; +using Scraping.Library; + +namespace Scraping +{ + public static class AudibleScraper + { + public static List ScrapeLibrarySources(params AudiblePageSource[] pageSources) + { + if (pageSources == null || !pageSources.Any()) + return new List(); + + if (pageSources.Select(ps => ps.AudiblePage).Distinct().Single() != AudiblePageType.Library) + throw new Exception("only Library items allowed"); + + return pageSources.SelectMany(s => scrapeLibraryPageSource(s)).ToList(); + } + private static List scrapeLibraryPageSource(AudiblePageSource pageSource) + => new LibraryScraper(pageSource) + .ScrapeCurrentPage() + // ScrapeCurrentPage() is long running. do not taunt delayed execution + .ToList(); + + public static BookDetailDTO ScrapeBookDetailsSource(AudiblePageSource pageSource) + { + ArgumentValidator.EnsureNotNull(pageSource, nameof(pageSource)); + + if (pageSource.AudiblePage != AudiblePageType.ProductDetails) + throw new Exception("only Product Details items allowed"); + + try + { + return new BookDetailScraper(pageSource).ScrapePage(); + } + catch (Exception ex) + { + throw; + } + } + } +} diff --git a/Scraping/UNTESTED/BookDetail/BookDetailDTO.cs b/Scraping/UNTESTED/BookDetail/BookDetailDTO.cs new file mode 100644 index 00000000..648c15de --- /dev/null +++ b/Scraping/UNTESTED/BookDetail/BookDetailDTO.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace Scraping.BookDetail +{ + public class SeriesEntry + { + public string SeriesId; + public string SeriesName; + public float? Index; + } + public class BookDetailDTO + { + public string ProductId { get; set; } + + /// DEBUG only + public string Title { get; set; } + + /// UNUSED: currently unused: desc from book-details is better desc in lib, but book-details also contains html tags + public string Description { get; set; } + + public bool IsAbridged { get; set; } + + // order matters: don't use hashtable/dictionary + public List Narrators { get; } = new List(); + + public string Publisher { get; set; } + + public DateTime DatePublished { get; set; } + + // order matters: don't use hashtable/dictionary + public List<(string categoryId, string categoryName)> Categories { get; } = new List<(string categoryId, string categoryName)>(); + + public List Series { get; } = new List(); + } +} diff --git a/Scraping/UNTESTED/BookDetail/BookDetailScraper.cs b/Scraping/UNTESTED/BookDetail/BookDetailScraper.cs new file mode 100644 index 00000000..c1d89de1 --- /dev/null +++ b/Scraping/UNTESTED/BookDetail/BookDetailScraper.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AudibleDotCom; +using Dinah.Core; +using Newtonsoft.Json.Linq; +using Scraping.Selectors; + +namespace Scraping.BookDetail +{ + static class NewtonsoftExt + { + public static string GetDecodedTokenString(this JToken jToken) => System.Net.WebUtility.HtmlDecode(((string)jToken).Trim()); + } + internal class BookDetailScraper + { + private AudiblePageSource source { get; } + private WebElement docRoot { get; } + + public BookDetailScraper(AudiblePageSource pageSource) + { + source = pageSource; + + var doc = new HtmlAgilityPack.HtmlDocument(); + doc.LoadHtml(source.Source); + docRoot = new WebElement(doc.DocumentNode); + } + + static RuleFamilyBD ruleFamily { get; } = new RuleFamilyBD + { + RowsLocator = By.XPath("/*"), + Rules = new RuleSetBD + { + parseJson, + parseSeries + } + }; + + public BookDetailDTO ScrapePage() + { + //debug//var sw = System.Diagnostics.Stopwatch.StartNew(); + + var returnBookDetailDto = new BookDetailDTO { ProductId = source.PageId }; + + var wholePage = ruleFamily.GetRows(docRoot).Single(); + ruleFamily.Rules.Run(wholePage, returnBookDetailDto); + + //debug//sw.Stop(); var ms = sw.ElapsedMilliseconds; + + return returnBookDetailDto; + } + + static void parseJson(WebElement row, BookDetailDTO productItem) + { + // structured data is in the 2nd of the 3 json embedded sections