GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
FileHelper.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Generic;
19using System.IO;
20using System.Linq;
21using System.Runtime.InteropServices;
22using System.Diagnostics;
23using System.Threading;
24using System.Text;
25using System.Text.Json;
26using System.Xml;
27using System.Xml.Serialization;
28using static GenerallyPositive.Enums;
29using YamlDotNet.Serialization;
30using YamlDotNet.Serialization.NamingConventions;
31using YamlDotNet.RepresentationModel;
32using YamlDotNet.Core;
33using Newtonsoft.Json;
34using System.Collections;
35using System.Net;
36using OpenQA.Selenium;
37using System.Drawing;
38using System.Text.RegularExpressions;
40using ClosedXML.Excel;
41using Newtonsoft.Json.Linq;
42using System.Linq.Expressions;
43using System.Windows.Forms.DataVisualization.Charting;
44
45namespace GenerallyPositive
46{
54 public class FileHelper
55 {
56 // Suppress repeat log messages (e.g. the "no delimiter set" notice fires once per parsed line - identical
57 // every time). We publish a given message once, then emit a single "Supressing repeat messages" notice.
58 private static readonly HashSet<string> _loggedMessages = new HashSet<string>();
59 private static bool _suppressNoticeShown = false;
60
65 private static void PublishSuppressingRepeats(GPALEventType type, string message, object source, GPALObjectType objectType)
66 {
67 if (true == _loggedMessages.Add(message))
68 GPAL.PublishSimpleEvent(type, message, source, objectType);
69 else if (false == _suppressNoticeShown)
70 {
71 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Supressing repeat messages", source, objectType);
72 _suppressNoticeShown = true;
73 }
74 }
75
81 internal static int GetGrid(GPALFileSettings fileSettings, out List<IGPALGrid<string>> tokens)
82 {
83 FileStream fileStream = null;
84 StreamReader streamReader = null;
85 string currentLine;
86 int fileIdx = 0;
87 List<IGPALGrid<string>> myTokens = new List<IGPALGrid<string>>();
88 IGPALGrid<string> myGrid = GPAL.Grid.ToGPALObject();
89
90 foreach (string filename in fileSettings.Filenames)
91 try
92 {
93 string directory = Path.GetDirectoryName(filename);
94 string filenamepart = Path.GetFileName(filename);
95 if (true == File.Exists(filename))
96 foreach (string filename2 in Directory.GetFiles(false == string.IsNullOrEmpty(directory) ? directory : @".\", filenamepart))
97 {
98 bool firstLineSkipped = false;
99
100 using (fileStream = System.IO.File.OpenRead(filename2))
101 {
102 using (streamReader = new StreamReader(fileStream))
103 {
104 while (null != (currentLine = streamReader?.ReadLine())) // get one line
105 {
106 if (false == firstLineSkipped && 0 < fileSettings.IgnoreFirstLineColumnNames.Count && fileSettings.IgnoreFirstLineColumnNames[0]) // CAVEAT: hardcoded value (but we always have one file now)
107 {
108 firstLineSkipped = true;
109 continue;
110 }
111 List<string> currentRow = new List<string>();
112 char delim;
113
114 if (0 < fileSettings.Delimiter.Count && true == fileSettings.Delimiter[fileIdx].HasValue)
115 delim = fileSettings.Delimiter[fileIdx].Value;
116 else
117 {
118 PublishSuppressingRepeats(GPALEventType.INFO, $"[{filename}] has no delimiter set, using comma.", fileSettings, GPALObjectType.FileSettings);
119 delim = ',';
120 }
121
122 foreach (string token in ConverterHelper.SplitRespectingQuotes(currentLine, delim)) // parse tokens and add to our grid, quote-aware so embedded delimiters/commas inside a quoted field don't split it
123 currentRow.Add(token);
124
125 myGrid.AddRow(currentRow); // add row to our grid
126 }
127 myTokens.Add(myGrid); // add grid to our Token list
128 fileSettings.FileGrid.Add(myGrid);
129 }
130 }
131 fileIdx++;
132 }
133 }
134 catch (Exception ex)
135 {
136 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to tokenize File [{filename}]. Tokens created [{myGrid.Rows}", fileSettings, GPALObjectType.FileSettings, ex);
137 }
138 finally
139 {
140 streamReader?.Close();
141 fileStream?.Close();
142 }
143 tokens = myTokens;
144 return tokens.Count();
145 }
153 internal static string CleanUpFileDestination(string filename, out string newFilename, bool DeleteFileBeforeDownload)
154 {
155 int counter = 0;
156 return CleanUpFileDestination(filename, out newFilename, DeleteFileBeforeDownload, Enums.NextFilePattern.Timestamp, ref counter);
157 }
164 internal static string CleanUpFileDestination(string filename, out string newFilename, bool DeleteFileBeforeDownload,
165 Enums.NextFilePattern nextFilePattern, ref int patternCounter)
166 {
167 string fullFilePath;
168 string currentDirectory = Path.GetDirectoryName(filename);
169
170 newFilename = filename;
171 if (true == string.IsNullOrEmpty(currentDirectory))
172 {
173 currentDirectory = ".\\"; // KnownFolders.GetPath(KnownFolder.Downloads);
174 fullFilePath = currentDirectory + filename;
175 }
176 else
177 fullFilePath = filename;
178
179 if (true == DeleteFileBeforeDownload)
180 {
181 try
182 {
183 if (true == File.Exists(fullFilePath))
184 {
185 System.IO.File.Delete(fullFilePath);
186 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Deleted file [{fullFilePath}].", null, GPALObjectType.None);
187 }
188 }
189 catch (Exception ex)
190 {
191 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to delete file [{fullFilePath}], Exception caught.", null, GPALObjectType.None, ex);
192 }
193 }
194 else if (true == System.IO.File.Exists(fullFilePath))
195 {
196 string baseName = Path.GetFileNameWithoutExtension(filename);
197 string extension = Path.GetExtension(filename);
198
199 if (Enums.NextFilePattern.Counter == nextFilePattern || Enums.NextFilePattern.CounterPadded == nextFilePattern)
200 {
201 // Seed the counter once from the highest existing "<base>_<n><ext>" via a single directory
202 // listing rather than probing _0001, _0002 ... one File.Exists at a time. This is O(1) syscalls
203 // per call after seeding, and (unlike a contiguous binary probe) stays correct when the numbering
204 // has gaps from deleted files. Zero means "not seeded yet"; the caller persists it thereafter.
205 if (0 == patternCounter)
206 patternCounter = HighestCounterIndex(currentDirectory, baseName, extension);
207
208 // max index + 1 is guaranteed free; the loop only re-spins if another process races in.
209 do
210 {
211 newFilename = string.Concat(baseName, NextFileSuffix(nextFilePattern, ref patternCounter), extension);
212 fullFilePath = currentDirectory + "\\" + newFilename;
213 }
214 while (true == System.IO.File.Exists(fullFilePath));
215 }
216 else
217 {
218 // timestamp/date patterns are already unique enough, taken as-is
219 newFilename = string.Concat(baseName, NextFileSuffix(nextFilePattern, ref patternCounter), extension);
220 fullFilePath = currentDirectory + "\\" + newFilename;
221 }
222 }
223
224 return fullFilePath;
225 }
231 private static int HighestCounterIndex(string directory, string baseName, string extension)
232 {
233 int max = 0;
234
235 try
236 {
237 string searchDir = string.IsNullOrEmpty(directory) ? "." : directory;
238 // '*' matches the number (and only the number, thanks to the digit check below)
239 string searchPattern = baseName + "_*" + extension;
240
241 foreach (string path in Directory.EnumerateFiles(searchDir, searchPattern))
242 {
243 string name = Path.GetFileNameWithoutExtension(path); // <base>_<digits>
244 int underscore = name.LastIndexOf('_');
245 if (0 > underscore || underscore + 1 >= name.Length)
246 continue;
247
248 string digits = name.Substring(underscore + 1);
249 if (true == digits.All(char.IsDigit) && true == int.TryParse(digits, out int n) && n > max)
250 max = n;
251 }
252 }
253 catch
254 {
255 // any listing failure just leaves max at 0; the File.Exists guard on the caller still keeps us correct
256 }
257
258 return max;
259 }
265 private static string NextFileSuffix(Enums.NextFilePattern pattern, ref int counter)
266 {
267 switch (pattern)
268 {
269 case Enums.NextFilePattern.DateTimeStamp:
270 return "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss");
271 case Enums.NextFilePattern.DateStamp:
272 return "_" + DateTime.Now.ToString("yyyyMMdd");
273 case Enums.NextFilePattern.Counter:
274 return "_" + (++counter).ToString();
275 case Enums.NextFilePattern.CounterPadded:
276 // D4 so up to 9999 files sort correctly; past that it just grows an extra digit
277 return "_" + (++counter).ToString("D4");
278 case Enums.NextFilePattern.Timestamp:
279 default:
280 return DateTime.Now.ToString("yyyyMMddHHmmssfff");
281 }
282 }
294 internal static bool DownloadToFile(string filename, string downloadUrl)
295 {
296 bool downloaded = false;
297 string url = downloadUrl;
298
299 try
300 {
301 using (System.Net.Http.HttpClient client = new System.Net.Http.HttpClient())
302 {
303 System.Net.Http.HttpRequestMessage request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, url);
304
305 // present ourselves as the browser would. plenty of sites refuse a request carrying no user
306 // agent at all, and being refused for that reason is not worth falling back to a whole browser
307 // over. Same thing CheckRobotsTxt does before it resorts to opening a tab.
308 // Chrome when nothing was said, since a GPALFile need not have a browser to take a type from
309 request.Headers.TryAddWithoutValidation("User-Agent",
310 true == string.IsNullOrWhiteSpace(GPAL.GPALSettings.UserAgent)
311 ? MagicHelper.GetUserAgentString(BrowserType.Chrome)
312 : GPAL.GPALSettings.UserAgent);
313
314 if (true == File.Exists(filename))
315 request.Headers.IfModifiedSince = File.GetLastWriteTimeUtc(filename);
316
317 System.Net.Http.HttpResponseMessage response = client.SendAsync(request).GetAwaiter().GetResult();
318
319 if (System.Net.HttpStatusCode.NotModified == response.StatusCode)
320 {
321 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{url}] has not changed, using [{filename}].", null, GPALObjectType.None);
322 downloaded = true;
323 }
324 else if (false == response.IsSuccessStatusCode)
325 {
326 // a 401 or 403 means the file is behind a session our own connection does not have.
327 // give the GPALFile a browser and it goes out on that browser's session instead
328 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{url}] returned HTTP [{(int)response.StatusCode}]. Give the file a browser and it is fetched on that browser's session instead", null, GPALObjectType.None);
329 }
330 else
331 {
332 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloading [{url}] to [{filename}].", null, GPALObjectType.None);
333 File.WriteAllBytes(filename, response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult());
334
335 // stamp it with the server's own modified time, so next run's question is about the content
336 // rather than about when we happened to fetch it
337 if (null != response.Content.Headers.LastModified)
338 File.SetLastWriteTimeUtc(filename, response.Content.Headers.LastModified.Value.UtcDateTime);
339
340 // nothing to wait for. WriteAllBytes returned, so the bytes are on disk and the file is
341 // whole. watching its size settle is for a browser writing in the background, and here it
342 // only confirmed a write we had already finished
343 downloaded = true;
344 }
345 }
346 }
347 catch (Exception ex)
348 {
349 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{url}] failed to save to [{filename}].", null, GPALObjectType.None, ex);
350 }
351
352 return downloaded;
353 }
359 internal static string GetDownloadUrl(GPALElement element)
360 {
361 // Check the type of element or its attributes to determine the appropriate URL for downloading
362 string downloadUrl = null;
363
364 // an image match has no tag or attributes of its own, so the page says what link it sits in
365 if ("GPALElement" == element.TagName)
366 {
367 downloadUrl = ElementHelper.UrlUnderImage(element);
368 }
369 // If the element is an <a> (anchor) tag, get the href attribute value
370 else if (element.TagName.ToLower() == "a")
371 {
372 downloadUrl = element.GetAttribute("href");
373 }
374 // If the element is an <img> (image) tag, get the src attribute value
375 else if (element.TagName.ToLower() == "img")
376 {
377 downloadUrl = element.GetAttribute("src");
378 }
379 // If the element is a <script> tag, get the src attribute value
380 else if (element.TagName.ToLower() == "script")
381 {
382 downloadUrl = element.GetAttribute("src");
383 }
384 // If the element is a <link> tag, get the href attribute value
385 else if (element.TagName.ToLower() == "link")
386 {
387 downloadUrl = element.GetAttribute("href");
388 }
389 // If the element is an <object> tag, get the data attribute value
390 else if (element.TagName.ToLower() == "object")
391 {
392 downloadUrl = element.GetAttribute("data");
393 }
394 // If the element is an <embed> tag, get the src attribute value
395 else if (element.TagName.ToLower() == "embed")
396 {
397 downloadUrl = element.GetAttribute("src");
398 }
399 // If the element is a <frame> or <iframe> tag, get the src attribute value
400 else if (element.TagName.ToLower() == "frame" || element.TagName.ToLower() == "iframe")
401 {
402 downloadUrl = element.GetAttribute("src");
403 }
404 // If the element is an <audio> tag, get the src attribute value
405 else if (element.TagName.ToLower() == "audio")
406 {
407 downloadUrl = element.GetAttribute("src");
408 }
409 // If the element is a <video> tag, get the src attribute value
410 else if (element.TagName.ToLower() == "video")
411 {
412 downloadUrl = element.GetAttribute("src");
413 }
414 // Add more conditions for other types of elements as needed...
415
416 return downloadUrl;
417 }
418 internal static FileSystemWatcher watcher;
419 internal static DateTime watchArmed; // when the watcher went up, so the file a previous download left in the same directory is not taken for this one
420 internal static readonly HashSet<string> watchedScratchFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); // the ones this download was seen to start, so an abandoned one can be told from anyone else's
421 // NOTE: BUG: CAVEAT: this never worked, maybe cause of permissions on the 'special' download folder?
422 internal static string SetupDownloadWatcher(string fullFilePathToSave, Browser.BrowserSettings browserSettings)
423 {
424 // chrome and edge are told to download straight into the destination directory before the click, so
425 // that is where to watch. the two that cannot be told go to the browser's own download directory and
426 // handleFile moves the file from there: ottomagic because the extension can name a download but not
427 // place it, firefox because it has no cdp and only reads its download directory at startup
428 bool downloadsWhereItLikes = true == browserSettings.UseOttoMagic
429 || BrowserType.FireFox == browserSettings.BrowserType;
430 string downloadDirectory = true == downloadsWhereItLikes
431 ? FileHelper.GetDefaultDownloadDirectory(browserSettings.Browser)
432 : Path.GetDirectoryName(fullFilePathToSave) ?? FileHelper.GetDefaultDownloadDirectory(browserSettings.Browser);
433
434 string fileName = Path.GetFileName(fullFilePathToSave);
435 string actualDownloadedFilename = null;
436
437 watchArmed = DateTime.Now;
438 watchedScratchFiles.Clear();
439
440 watcher = new FileSystemWatcher(downloadDirectory);
441 watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes;
442 watcher.Filter = "*.*";
443 watcher.EnableRaisingEvents = true;
444
445 // Add Created (often fires for temp file start)
446 watcher.Created += (sender, e) => handleFile(sender, e, downloadDirectory, fullFilePathToSave);
447 watcher.Changed += (sender, e) => handleFile(sender, e, downloadDirectory, fullFilePathToSave);
448 watcher.Renamed += (sender, e) => handleFile(sender, e, downloadDirectory, fullFilePathToSave);
449
450 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Watching [{downloadDirectory}] for [{fileName}] w/timeout [{browserSettings.DownloadTimeoutInSec}] secs", null, GPALObjectType.None);
451 System.Threading.Thread.Sleep(1_000);
452
453 return downloadDirectory;
454
455 void handleFile(object sender, FileSystemEventArgs e, string downloaddirectory, string fullFilePathToSave)
456 {
457 try
458 {
459 // a browser downloads to a temporary name and renames it to the real one when it finishes, and
460 // the name it lands under is the site's, not the temporary one with its suffix taken off. the
461 // rename is the only event that carries where the file actually went
462 if (e is RenamedEventArgs renamed
463 && (true == renamed.OldName.EndsWith(".crdownload") || true == renamed.OldName.EndsWith(".part")))
464 {
465 actualDownloadedFilename = WaitForDownloadToFinish(Path.Combine(downloaddirectory, renamed.Name), browserSettings.DownloadTimeoutInSec);
466 browserSettings.FileDownloaded = false == string.IsNullOrEmpty(actualDownloadedFilename);
467
468 if (true == browserSettings.FileDownloaded)
469 {
470 File.Move(actualDownloadedFilename, fullFilePathToSave);
471 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{actualDownloadedFilename}] moved to [{fullFilePathToSave}]", null, GPALObjectType.None);
472 }
473 else
474 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{renamed.OldName}] was renamed to [{renamed.Name}] but it never settled", null, GPALObjectType.None);
475
476 // only once it is done. these events keep arriving and a later one can resolve what this
477 // one could not, so standing the watcher down on a miss is what makes a miss permanent
478 if (true == browserSettings.FileDownloaded)
479 ((FileSystemWatcher)sender).Dispose();
480
481 return;
482 }
483
484 if (e.Name.EndsWith(".crdownload") || e.Name.EndsWith(".part"))
485 {
486 string filename = e.Name.Replace(".crdownload", "").Replace(".part", "");
487 string fullSavedFilePath = Path.Combine(downloaddirectory, filename); // Safer path building
488
489 string pattern = @"^downloads(?:\.htm|\s*\‍(\d+\‍)\.htm|\.htm\s*\‍(\d+\‍))$";
490 if (Regex.IsMatch(filename, pattern, RegexOptions.IgnoreCase))
491 {
492 return;
493 }
494
495 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Download started for [{e.Name}] waiting on expected [{fullSavedFilePath}]", null, GPALObjectType.None);
496 watchedScratchFiles.Add(Path.Combine(downloaddirectory, e.Name));
497
498 // disable events temporarily to avoid duplicate calls during wait
499 // watcher.EnableRaisingEvents = false;
500
501 // first check and wait for the crdownload file to finish, which might finish before we even call, so it never finds it...
502 if (true == File.Exists(Path.Combine(downloaddirectory, e.Name)))
503 actualDownloadedFilename = WaitForDownloadToFinish(Path.Combine(downloaddirectory, e.Name), browserSettings.DownloadTimeoutInSec);
504
505 // the scratch file is never the download. WaitForDownloadToFinish hands its name back to
506 // say it watched one and it has gone, which is the browser renaming it to the real name,
507 // so drop it here and let the real name be found below. moving it is how a download that
508 // stalled with a steady size, or one still being written, ended up saved as the answer
509 if (true == IsTemporaryDownload(actualDownloadedFilename))
510 actualDownloadedFilename = null;
511
512 // the destination itself. when the filename was set for this download the browser renames
513 // the temporary file straight to it, so it is already where it was asked to go
514 if (true == File.Exists(fullFilePathToSave))
515 actualDownloadedFilename = WaitForDownloadToFinish(fullFilePathToSave, browserSettings.DownloadTimeoutInSec);
516
517 // and failing that the temporary name with its suffix taken off, for a browser that lands
518 // it there. only when nothing above answered - asking regardless threw away what we had
519 if (true == string.IsNullOrEmpty(actualDownloadedFilename))
520 actualDownloadedFilename = WaitForDownloadToFinish(fullSavedFilePath, browserSettings.DownloadTimeoutInSec);
521
522 browserSettings.FileDownloaded = !string.IsNullOrEmpty(actualDownloadedFilename);
523
524 if (true == browserSettings.FileDownloaded)
525 {
526 // the browser was told where to put it and did, so there is nothing to move and
527 // File.Move onto itself only throws
528 if (true == fullFilePathToSave.Equals(actualDownloadedFilename, StringComparison.OrdinalIgnoreCase))
529 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{fullFilePathToSave}] downloaded straight to where it was asked to go", null, GPALObjectType.None);
530 else
531 {
532 File.Move(actualDownloadedFilename, fullFilePathToSave);
533 if (File.Exists(actualDownloadedFilename))
534 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Unable to move [{actualDownloadedFilename}] to [{fullFilePathToSave}]", null, GPALObjectType.None);
535 else
536 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{actualDownloadedFilename}] moved to [{fullFilePathToSave}]", null, GPALObjectType.None);
537 }
538 }
539 else
540 {
541 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"WaitForDownloadToFinish returned null for [{fullSavedFilePath}] - check timeout or stability", null, GPALObjectType.None);
542 }
543
544 // as above: a browser still finalizing has not renamed anything yet, and the event that
545 // says it did is the one this would have thrown away
546 if (true == browserSettings.FileDownloaded)
547 ((FileSystemWatcher)sender).Dispose();
548 }
549 }
550 catch (Exception ex)
551 {
552 // the watcher stays up and the flag stays as it was. these events arrive many times over one
553 // download and any of them can catch the directory mid-change, so this is one event going
554 // wrong rather than the download failing. shutting the watcher down here meant the download
555 // could never be reported, and answering false here would end the wait on the same mistake
556 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Error for [{fullFilePathToSave}]", null, GPALObjectType.None, ex);
557 }
558 }
559 }
560
567 internal const int DownloadSizeSampleMs = 500; // long enough that a file still being written is seen to grow
568
574 internal static bool IsTemporaryDownload(string path)
575 {
576 bool retVal = true == path?.EndsWith(".crdownload") || true == path?.EndsWith(".part");
577
578 return retVal;
579 }
580
593 internal static string NewestDownloadSince(string directory, DateTime since)
594 {
595 string retVal = null;
596 DateTime newest = DateTime.MinValue;
597
598 foreach (string candidate in Directory.EnumerateFiles(directory))
599 if (false == IsTemporaryDownload(candidate))
600 {
601 // the later of the two, because either one on its own can read older than the file is. a
602 // fetched file is stamped with the server's Last-Modified and its write time can be years
603 // old, and windows hands a file recreated under a name it just used the original's creation
604 // time. both being stale at once is the one thing that does not happen
605 DateTime written = File.GetLastWriteTime(candidate);
606 DateTime created = File.GetCreationTime(candidate);
607 DateTime arrived = created > written ? created : written;
608
609 if (arrived >= since && arrived > newest)
610 {
611 newest = arrived;
612 retVal = candidate;
613 }
614 }
615
616 return retVal;
617 }
618
619 internal static string WaitForDownloadToFinish(string filePath, int downloadTimeoutInSec)
620 {
621 long size = 0, newSize = 0;
622 string downloadedFilename;
623 bool fileStarted = false;
624
625 newSize = WaitForFileToDownload(filePath, downloadTimeoutInSec, size, out downloadedFilename); // -1 return if timed out waiting for file to exist (not completed)
626
627 // KLUDGE: file showed up but is zero bytes, this could be correct, but let's give an extra second and wait
628 // NOTE: hardcoded value - not really in favor of hard sleeps during the workflow espcially since the download has started, but maybe it's slow for some reason
629 // our file checks can run very quuckly
630 if (0 == newSize)
631 System.Threading.Thread.Sleep(1_000);
632 else if (-1 == newSize)
633 {
634 if (false == filePath.EndsWith(".crdownload") && false == filePath.EndsWith(".part"))
635 return string.Empty;
636 else
637 return filePath;
638 }
639
640 size = newSize;
641
642 // NOTE: this is prolly really overkill, essentially checking multiple times for completion
643 do
644 {
645 if (0 <= (newSize = WaitForFileToDownload(filePath, downloadTimeoutInSec, newSize, out downloadedFilename)))
646 {
647 // NOTE: new scenario, race condition, if file starts download then chrome renames, we get -1 and think it is not downloaded
648 fileStarted = true;
649
650 // KLUDGE: file showed up but is zero bytes, this could be correct, but let's give an extra second and wait
651 // NOTE: hardcoded value - not really in favor of hard sleeps during the workflow espcially since the download has started, but maybe it's slow for some reason
652 // our file checks can run very quuckly
653 if (0 == newSize)
654 System.Threading.Thread.Sleep(1_000);
655
656 // a file being written has to be given time to grow between the two reads. taking both in the
657 // same instant made every download look finished the moment it was first seen, which is how a
658 // part-written file was taken for a complete one
659 System.Threading.Thread.Sleep(DownloadSizeSampleMs);
660
661 if (size == (newSize = WaitForFileToDownload(filePath, downloadTimeoutInSec, newSize, out downloadedFilename))) // NOTE: ensure the filesize stays the same (prolly overkill)
662 break;
663 else
664 size = newSize;
665 }
666 else if (-1 == newSize) // file isn't there..
667 break;
668 } while (true); // until we break when the file szie no longer changes
669
670 if (false == filePath.EndsWith(".crdownload") && false == filePath.EndsWith(".part"))
671 {
672 if (0 == newSize)
673 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"File [{downloadedFilename ?? filePath}] downloaded as zero bytes", null, GPALObjectType.None);
674 else if (-1 == newSize && false == fileStarted)
675 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"File [{downloadedFilename ?? filePath}] NOT downloaded", null, GPALObjectType.None);
676 // NOTE: if -1 and file started, we have a race condition where the file was renamed, prolly by the browser - so ignore it, it's just a temp file
677 }
678
679 return downloadedFilename;
680 }
681 internal static long WaitForFileToDownload(string filePath, int downloadTimeoutInSec, long size, out string downloadedFilename)
682 {
683 TimeSpan timeout = TimeSpan.FromSeconds(downloadTimeoutInSec); // Adjust timeout as needed
684 DateTime startTime = DateTime.Now;
685 bool fileExists = Directory.GetFiles(Path.GetDirectoryName(filePath), Path.GetFileName(filePath)).Length > 0;
686 int lastPrinted = -1; // so we print at ~30, 20, 10, 0
687
688 // a scratch file is not something to wait for. the caller only ever asks about one it has already
689 // seen, so it not being there is the browser having renamed it, which is the download finishing.
690 // the rename never says what it renamed to, so the download is whatever arrived in that directory
691 // just now, and sizing that up is what the rest of this does. waiting out the timeout for a name
692 // that has already changed is how a download that worked was reported as one that never came
693 if (false == fileExists && true == IsTemporaryDownload(filePath))
694 {
695 string arrived = NewestDownloadSince(Path.GetDirectoryName(filePath), watchArmed);
696
697 if (true == string.IsNullOrEmpty(arrived))
698 {
699 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{filePath}] has gone and nothing arrived with it, so the download did not finish.", null, GPALObjectType.None);
700 downloadedFilename = filePath;
701
702 return -1;
703 }
704
705 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{Path.GetFileName(filePath)}] became [{Path.GetFileName(arrived)}], which is what the browser called it.", null, GPALObjectType.None);
706 filePath = arrived;
707 fileExists = true;
708 }
709
710 while (false == fileExists && (DateTime.Now - startTime) < timeout)
711 {
712 TimeSpan remaining = timeout - (DateTime.Now - startTime);
713 int remainingSec = (int)Math.Ceiling(remaining.TotalSeconds); // or (int)remaining.TotalSeconds
714
715 // Print only when crossing a 10-second boundary (30>29, 20>19, etc.)
716 if (remainingSec % 10 == 0 && remainingSec != lastPrinted)
717 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Waiting up tp [{remainingSec}] sec for [{filePath}].", null, GPALObjectType.None);
718 // Sleep for a short interval before checking again
719 System.Threading.Thread.Sleep(1000); // Check every second
720 fileExists = Directory.GetFiles(Path.GetDirectoryName(filePath), Path.GetFileName(filePath)).Length > 0;
721
722 // the name we were handed is one the browser may have already moved on from. it renames the file
723 // as the download finishes and the name it picks is its own, so "Unconfirmed 175338" was never
724 // going to turn up. a file that arrived while we sat here is that download under its new name,
725 // so take it and stop waiting rather than run the timeout out on a name that has changed
726 if (false == fileExists)
727 {
728 string arrived = NewestDownloadSince(Path.GetDirectoryName(filePath), watchArmed);
729
730 if (false == string.IsNullOrEmpty(arrived) && false == arrived.Equals(filePath, StringComparison.OrdinalIgnoreCase))
731 {
732 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{Path.GetFileName(filePath)}] never showed up, [{Path.GetFileName(arrived)}] did, which is the name the browser gave it.", null, GPALObjectType.None);
733 filePath = arrived;
734 fileExists = true;
735 }
736 }
737 }
738
739 if (false == fileExists)
740 {
741 if (false == filePath.EndsWith(".crdownload") && false == filePath.EndsWith(".part"))
742 {
743 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"File [{filePath}] never showed up.", null, GPALObjectType.None);
744 downloadedFilename = null;
745 }
746 else // if we have a temp file, we actually detected it, so return it, it existed and next step will be to check the real filename
747 downloadedFilename = filePath;
748
749 return -1;
750 }
751
752 // we got here in case a wildcard is passed in and we have multiple matching files
753
754 // use case: we expect a file dialog but the link downloads directly, so we have to figure it out post download
755 // as it will not download to the correct directory, so find where it downloads to and copy to the user specified directory.
756 // NOTE: CAVEAT: this depends upon the user supplied file extension which must match the expected download
757 var cutoff = DateTime.Now.AddMinutes(-2); // NOTE: hardcoded value
758
759 string newest = null;
760 DateTime newestTime = DateTime.MinValue;
761 IEnumerable<string> filesFound = Directory.EnumerateFiles(
762 Path.GetDirectoryName(filePath),
763 Path.GetFileName(filePath));
764
765 // there is more than one file that matches our filePath mask, so a wildcard must be in play
766 if (1 < filesFound.Count())
767 foreach (var f in filesFound)
768 {
769 // we may have just run the auto-update, do not mistake one of these files for our download
770 if (true == f.Equals(GPAL.GPALSettings.ChromeDriverZipFilename) ||
771 true == f.Equals(GPAL.GPALSettings.EdgeDriverFilename) ||
772 true == f.Equals(GPAL.GPALSettings.FirefoxDriverFilename))
773 continue;
774
775 var t = File.GetLastWriteTime(f);
776
777 if (t >= cutoff && t > newestTime)
778 {
779 newestTime = t;
780 newest = f;
781 }
782 }
783 else // only one file, get it
784 newest = filesFound.FirstOrDefault();
785
786 // it was there at the check above and it is gone now, which is the browser renaming it the moment it
787 // finished. that is the download completing, so say what the not-found path says and let the caller go
788 // looking for the name it landed under. First() on an empty match threw out of here instead, and the
789 // handler that caught it turned off the watcher, so nothing was ever going to report the file
790 if (null == newest)
791 {
792 downloadedFilename = true == IsTemporaryDownload(filePath) ? filePath : null;
793
794 return -1;
795 }
796
797 if (true == newest.Equals(GPAL.GPALSettings.ChromeDriverZipFilename) ||
798 true == newest.Equals(GPAL.GPALSettings.EdgeDriverFilename) ||
799 true == newest.Equals(GPAL.GPALSettings.FirefoxDriverFilename))
800 newest = null;
801
802 // grab the newest file that showed up in the last 2 minutes. we should be waiting for just one file.
803 if (null != newest)
804 {
805 downloadedFilename = newest;
806
807 // Once the file exists, attempt to open it
808 try
809 {
810 using (FileStream fs = File.Open(downloadedFilename, FileMode.Open))
811 {
812 // If opening the file succeeds, the download is complete
813 if (size != fs.Length)
814 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"File [{downloadedFilename}] downloaded with file size [{fs.Length}].", null, GPALObjectType.None);
815 return fs.Length;
816 }
817 }
818 catch
819 {
820 try
821 {
822 // the file would not open, so it is held by whoever is writing it or by a reader. that is
823 // not an answer either way, so report the size it is at and let the caller's next sample
824 // decide: a file still being written grows, a finished one does not
825 FileInfo fileInfo = new FileInfo(downloadedFilename);
826
827 if (fileInfo.Exists)
828 {
829 // If opening the file succeeds, the download is complete
830 if (size != fileInfo.Length)
831 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"File [{downloadedFilename}] locked, using FileInfo size [{fileInfo.Length}].", null, GPALObjectType.None);
832
833 return fileInfo.Length;
834 }
835 }
836 catch (Exception ex)
837 {
838 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"File [{downloadedFilename}] unable to determine file size.", null, GPALObjectType.None, ex);
839 return -1;
840 }
841 }
842 }
843 else
844 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"File [{filePath}] not found.", null, GPALObjectType.None);
845
846 downloadedFilename = null;
847
848 return -1;
849 }
850
858 public static string GetDefaultDownloadDirectory(IBrowser browser)
859 {
860 if (BrowserType.Chrome == browser.BrowserType)
861 return GetChromeDefaultDownloadDirectory(((Browser.Browser)browser).BrowserSettings.ProfileDataDirectory);
862 else if (BrowserType.Edge == browser.BrowserType)
863 return GetEdgeDefaultDownloadDirectory(((Browser.Browser)browser).BrowserSettings.ProfileDataDirectory);
864 else if (BrowserType.FireFox == browser.BrowserType)
865 return GetFirefoxDefaultDownloadDirectory(((Browser.Browser)browser).BrowserSettings.ProfileDataDirectory);
866
867 // Get the default download directory used by windows
868 string defaultDownloadDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + @"\Downloads\";
869 return defaultDownloadDirectory;
870 }
871
877 public static string GetChromeDefaultDownloadDirectory(string profilePath = null)
878 {
879 // Default profile path (change "Default" to "Profile 1" etc. if using a different one)
880 if (string.IsNullOrEmpty(profilePath))
881 {
882 string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
883 profilePath = Path.Combine(localAppData, @"Google\Chrome\User Data\Default");
884 }
885
886 string prefsPath = null;
887
888 if (false == new DirectoryInfo(profilePath).Name.ToLower().Equals("default"))
889 prefsPath = Path.Combine(profilePath, @"default\Preferences");
890 else
891 prefsPath = Path.Combine(profilePath, @"Preferences");
892
893 if (File.Exists(prefsPath))
894 {
895 string json = File.ReadAllText(prefsPath);
896 JObject prefs = JObject.Parse(json);
897
898 // Navigate to download.default_directory
899 var downloadDir = prefs["download"]?["default_directory"]?.ToString();
900
901 if (!string.IsNullOrEmpty(downloadDir))
902 return downloadDir;
903
904 // If not set explicitly, Chrome uses the system default Downloads folder
905 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
906 }
907
908 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); ;
909 }
910
917 public static string GetEdgeDefaultDownloadDirectory(string profilePath = null)
918 {
919 // Default profile path ("Default" folder; change to "Profile 1" etc. if needed)
920 if (string.IsNullOrEmpty(profilePath))
921 {
922 string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
923 profilePath = Path.Combine(localAppData, @"Microsoft\Edge\User Data\Default");
924 }
925
926 string prefsPath = Path.Combine(profilePath, @"default\Preferences");
927
928 if (File.Exists(prefsPath))
929 {
930
931 string json = File.ReadAllText(prefsPath);
932 JObject prefs = JObject.Parse(json);
933
934 // download.default_directory only. savefile.default_directory is where "save page as" last put
935 // something, a different setting that happens to look like this one, and it is never where a
936 // download goes. reading it at all sends the download watcher somewhere the browser does not
937 // download to: first when it was preferred, then again as a fallback once a profile had no
938 // download directory of its own. a profile without one uses the windows Downloads folder, which
939 // is what the fallback below already answers
940 var downloadDir = prefs["download"]?["default_directory"]?.ToString();
941
942 if (!string.IsNullOrEmpty(downloadDir))
943 return downloadDir;
944
945 // Fallback: system default Downloads folder
946 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
947 }
948
949 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
950 }
951
958 public static string GetFirefoxDefaultDownloadDirectory(string profilePath = null)
959 {
960 string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
961 string firefoxDir = Path.Combine(appData, @"Mozilla\Firefox");
962 string profilesIniPath = Path.Combine(firefoxDir, "profiles.ini");
963
964 if (!File.Exists(profilesIniPath))
965 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Firefox profiles.ini [{profilesIniPath}] not found.");
966
967 // Read and manually parse profiles.ini to find the default profile
968 string[] lines = File.ReadAllLines(profilesIniPath);
969 string profileDir = null;
970 bool isRelative = true;
971
972 for (int i = 0; i < lines.Length; i++)
973 {
974 string line = lines[i].Trim();
975
976 if (line.StartsWith("[Profile", StringComparison.OrdinalIgnoreCase))
977 {
978 // Reset for new section
979 string currentPath = null;
980 bool currentIsRelative = true;
981 bool isDefault = false;
982
983 // Look ahead in this section
984 for (int j = i + 1; j < lines.Length; j++)
985 {
986 string subLine = lines[j].Trim();
987 if (subLine.StartsWith("["))
988 break; // next section
989
990 if (subLine.StartsWith("Path=", StringComparison.OrdinalIgnoreCase))
991 currentPath = subLine.Substring(5);
992 else if (subLine.StartsWith("IsRelative=", StringComparison.OrdinalIgnoreCase))
993 currentIsRelative = subLine.Substring(11) == "1";
994 else if (subLine.StartsWith("Default=", StringComparison.OrdinalIgnoreCase))
995 isDefault = subLine.Substring(8) == "1";
996 }
997
998 // If this is the default profile, use it
999 if (isDefault && currentPath != null)
1000 {
1001 profileDir = currentPath;
1002 isRelative = currentIsRelative;
1003 break;
1004 }
1005
1006 // Otherwise remember the first profile we find as fallback
1007 if (profileDir == null && currentPath != null)
1008 {
1009 profileDir = currentPath;
1010 isRelative = currentIsRelative;
1011 }
1012 }
1013 }
1014
1015 if (!string.IsNullOrEmpty(profileDir))
1016 {
1017
1018 // Resolve full path
1019 string fullProfilePath = isRelative
1020 ? Path.Combine(firefoxDir, profileDir)
1021 : profileDir;
1022
1023 // Allow override
1024 if (!string.IsNullOrEmpty(profilePath))
1025 fullProfilePath = profilePath;
1026
1027 string prefsJsPath = Path.Combine(fullProfilePath, "prefs.js");
1028
1029 if (!File.Exists(prefsJsPath))
1030 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"prefs.js [{prefsJsPath}] not found in Firefox profile.");
1031 else
1032 {
1033 string content = File.ReadAllText(prefsJsPath);
1034
1035 // Look for: user_pref("browser.download.dir", "C:\\Path\\To\\Folder");
1036 var match = Regex.Match(content, @"user_pref\‍(""browser\.download\.dir"",\s*""([^""]*)""\‍);");
1037
1038 if (match.Success)
1039 {
1040 string dir = match.Groups[1].Value;
1041 // Handle common escapes (\\ > \‍)
1042 return dir.Replace("\\\\", "\\");
1043 }
1044
1045 // Not set > fallback to user's Downloads folder
1046 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
1047 }
1048 }
1049
1050 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
1051 }
1052
1057 internal static List<IGPALGrid<string>> TokenizeFile(UnitOfWork currentUOW, GPALFile inputFile)
1058 {
1059 currentUOW.InputFile = inputFile;
1060 int rowCount = FileHelper.GetGrid(((IGPALFileInternal)inputFile).FileSettings, out List<IGPALGrid<string>> tokens);
1061 return tokens;
1062 }
1063
1068 private static HashSet<string> CollectDictionaryKeys(object value)
1069 {
1070 var keys = new HashSet<string>();
1071 if (value is IList<object> list)
1072 {
1073 foreach (var item in list)
1074 {
1075 if (item is IDictionary dynamicDict)
1076 {
1077 foreach (dynamic entry in dynamicDict)
1078 {
1079 string key = entry.Key?.ToString();
1080 if (key != null)
1081 {
1082 if (entry.Value is IDictionary nestedDict)
1083 {
1084 foreach (string nestedKey in CollectDictionaryKeys(nestedDict))
1085 {
1086 keys.Add($"{key}.{nestedKey}");
1087 }
1088 }
1089 else
1090 {
1091 keys.Add(key);
1092 }
1093 }
1094 }
1095 }
1096 }
1097 }
1098 else if (value is IDictionary dynamicDict)
1099 {
1100 foreach (dynamic entry in dynamicDict)
1101 {
1102 string key = entry.Key?.ToString();
1103 if (key != null)
1104 {
1105 if (entry.Value is IDictionary nestedDict)
1106 {
1107 foreach (string nestedKey in CollectDictionaryKeys(nestedDict))
1108 {
1109 keys.Add($"{key}.{nestedKey}");
1110 }
1111 }
1112 else
1113 {
1114 keys.Add(key);
1115 }
1116 }
1117 }
1118 }
1119 return keys;
1120 }
1121
1122 // the ConverterSettings this used to take was never read in the body, only handed to its own recursive
1123 // calls, so caller settings had no effect on dictionary or grid processing either way
1124 internal static object ProcessDictionaryValue(object value, List<List<string>> gridRows = null, HashSet<string> gridColumns = null, string parentKey = null, HashSet<object> visited = null)
1125 {
1126 if (value == null)
1127 {
1128 if (gridRows != null && gridColumns == null)
1129 {
1130 gridRows.Add(new List<string> { "" });
1131 }
1132 return null;
1133 }
1134
1135 Type valueType = value.GetType();
1136
1137 if (visited == null)
1138 visited = new HashSet<object>(ConverterHelper.ReferenceEqualityComparer.Instance);
1139
1140 // Handle nested dictionaries
1141 if (value is IDictionary dynamicDict)
1142 {
1143 // YAML anchors/aliases can produce genuine circular object graphs (e.g. a
1144 // dictionary that contains itself via a nested list). Without this check
1145 // such cycles cause unbounded recursion and a StackOverflowException.
1146 if (!visited.Add(value))
1147 return $"Recursion to [{parentKey ?? valueType.Name}]";
1148
1149 try
1150 {
1151 var resultDict = new Dictionary<dynamic, dynamic>();
1152 var row = new List<string>();
1153
1154 if (gridColumns != null && gridRows != null)
1155 {
1156 row = new List<string>(new string[gridColumns.Count]);
1157 int colIdx = 0;
1158 foreach (string col in gridColumns)
1159 {
1160 string cellValue = "";
1161 if (col.Contains("."))
1162 {
1163 var keyParts = col.Split('.');
1164 object current = dynamicDict;
1165 bool found = true;
1166 foreach (var part in keyParts)
1167 {
1168 if (current is IDictionary currentDict && currentDict.Contains(part))
1169 {
1170 current = currentDict[part];
1171 }
1172 else
1173 {
1174 found = false;
1175 break;
1176 }
1177 }
1178 if (found)
1179 {
1180 var processedValue = ProcessDictionaryValue(current, null, null, null, visited);
1181 cellValue = FlattenValueForGrid(processedValue);
1182 }
1183 }
1184 else if (dynamicDict.Contains(col))
1185 {
1186 var processedValue = ProcessDictionaryValue(dynamicDict[col], null, null, null, visited);
1187 cellValue = FlattenValueForGrid(processedValue);
1188 }
1189 row[colIdx++] = cellValue;
1190 }
1191 gridRows.Add(row);
1192 }
1193
1194 foreach (dynamic entry in dynamicDict)
1195 {
1196 string key = entry.Key?.ToString();
1197 if (key != null)
1198 {
1199 bool isTabularList = entry.Value is IList<object> list2 && list2.Count() > 0 && list2[0] is IDictionary;
1200 HashSet<string> nestedColumns = isTabularList ? CollectDictionaryKeys(entry.Value) : null;
1201 var processedValue = ProcessDictionaryValue(entry.Value, isTabularList ? gridRows : null, nestedColumns, key, visited);
1202 resultDict[key] = processedValue;
1203 if (!isTabularList && gridRows != null && gridColumns == null)
1204 {
1205 row.Add(FlattenValueForGrid(processedValue));
1206 }
1207 }
1208 }
1209
1210 if (gridRows != null && row.Count > 0 && gridColumns == null)
1211 {
1212 gridRows.Add(row);
1213 }
1214 return resultDict;
1215 }
1216 finally
1217 {
1218 visited.Remove(value);
1219 }
1220 }
1221
1222 // Handle arrays/lists
1223 if (value is IList<object> list && !(value is string))
1224 {
1225 if (!visited.Add(value))
1226 return $"Recursion to [{parentKey ?? valueType.Name}]";
1227
1228 try
1229 {
1230 var resultList = new List<object>();
1231 string itemKey = string.Empty;
1232
1233 if (null != parentKey)
1234 itemKey = parentKey;
1235 else
1236 itemKey = "item";
1237
1238 foreach (var item in list)
1239 {
1240 var processedItem = ProcessDictionaryValue(item, gridRows, gridColumns, itemKey, visited);
1241 resultList.Add(processedItem);
1242 }
1243 return resultList;
1244 }
1245 finally
1246 {
1247 visited.Remove(value);
1248 }
1249 }
1250
1251 // Handle simple types
1252 if (ConverterHelper.IsSimpleType(valueType))
1253 {
1254 string stringValue;
1255 if (valueType == typeof(Guid) && Guid.TryParse(value.ToString(), out var guid))
1256 {
1257 stringValue = guid.ToString();
1258 if (gridRows != null && gridColumns == null)
1259 {
1260 gridRows.Add(new List<string> { stringValue });
1261 }
1262 return guid;
1263 }
1264 if (valueType == typeof(IPAddress) && IPAddress.TryParse(value.ToString(), out var ipAddress))
1265 {
1266 stringValue = ipAddress.ToString();
1267 if (gridRows != null && gridColumns == null)
1268 {
1269 gridRows.Add(new List<string> { stringValue });
1270 }
1271 return ipAddress;
1272 }
1273 if (valueType == typeof(TimeSpan) && TimeSpan.TryParse(value.ToString(), out var timeSpan))
1274 {
1275 stringValue = timeSpan.ToString();
1276 if (gridRows != null && gridColumns == null)
1277 {
1278 gridRows.Add(new List<string> { stringValue });
1279 }
1280 return timeSpan;
1281 }
1282 if (valueType == typeof(Uri))
1283 {
1284 try
1285 {
1286 var uri = new Uri(value.ToString());
1287 stringValue = uri.ToString();
1288 if (gridRows != null && gridColumns == null)
1289 {
1290 gridRows.Add(new List<string> { stringValue });
1291 }
1292 return uri;
1293 }
1294 catch
1295 {
1296 stringValue = value.ToString();
1297 if (gridRows != null && gridColumns == null)
1298 {
1299 gridRows.Add(new List<string> { stringValue });
1300 }
1301 return stringValue;
1302 }
1303 }
1304 if (valueType == typeof(System.Numerics.BigInteger))
1305 {
1306 stringValue = value.ToString();
1307 if (gridRows != null && gridColumns == null)
1308 {
1309 gridRows.Add(new List<string> { stringValue });
1310 }
1311 return stringValue;
1312 }
1313 if (valueType == typeof(System.Version))
1314 {
1315 stringValue = value.ToString();
1316 if (gridRows != null && gridColumns == null)
1317 {
1318 gridRows.Add(new List<string> { stringValue });
1319 }
1320 return stringValue;
1321 }
1322 stringValue = value.ToString();
1323 if (gridRows != null && gridColumns == null)
1324 {
1325 gridRows.Add(new List<string> { stringValue });
1326 }
1327 return value;
1328 }
1329
1330 // Handle complex objects
1331 if (ConverterHelper.IsClassType(valueType) && !valueType.IsArray)
1332 {
1333 var resultDict = ConverterHelper.ConvertClassToDictionary(value);
1334 var row = new List<string>();
1335 foreach (dynamic entry in resultDict)
1336 {
1337 row.Add(FlattenValueForGrid(entry.Value));
1338 }
1339 if (gridRows != null && row.Count > 0 && gridColumns == null)
1340 {
1341 gridRows.Add(row);
1342 }
1343 return resultDict;
1344 }
1345
1346 // Fallback for unknown types
1347 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Unknown value type [{valueType}] detected. Converting to string.", value, GPALObjectType.Other);
1348 string fallbackValue = value.ToString();
1349 if (gridRows != null && gridColumns == null)
1350 {
1351 gridRows.Add(new List<string> { fallbackValue });
1352 }
1353 return fallbackValue;
1354 }
1355
1356 private static string FlattenValueForGrid(object value)
1357 {
1358 if (value == null)
1359 {
1360 return "";
1361 }
1362
1363 if (value is IDictionary dynamicDict)
1364 {
1365 var values = new List<string>();
1366 foreach (dynamic entry in dynamicDict)
1367 {
1368 values.Add(FlattenValueForGrid(entry.Value));
1369 }
1370 return string.Join(",", values);
1371 }
1372
1373 if (value is IEnumerable<object> enumerable && !(value is string))
1374 {
1375 var values = new List<string>();
1376 foreach (var item in enumerable)
1377 {
1378 values.Add(FlattenValueForGrid(item));
1379 }
1380 return string.Join(",", values);
1381 }
1382
1383 return value.ToString();
1384 }
1385
1396 private static IGPALGrid<string> ColumnNamesForFile(GPALFile inputFile, int fileIdx)
1397 {
1398 IGPALGrid<string> columnList = ((IGPALFileInternal)inputFile).FileSettings.ColumnList;
1399 IGPALGrid<string> retVal = columnList;
1400
1401 if (false == ((IGPALFileInternal)inputFile).FileSettings.UseOneHeaderForAllFiles
1402 && 0 < fileIdx
1403 && fileIdx < columnList.Count())
1404 {
1405 retVal = GPAL.GridForType<string>();
1406 retVal.AddRow(new List<string>(columnList[fileIdx]));
1407 }
1408
1409 return retVal;
1410 }
1411
1412 internal static bool TokenizeFile(GPALFile inputFile, ConverterSettings myConverterSettings = null)
1413 {
1414 var xmlDoc = new XmlDocument();
1415 int fileIdx = 0;
1416 StreamReader input = null;
1417 IGPALGrid<string> outGrid = null;
1418 List<IGPALGrid<string>> tokens = new List<IGPALGrid<string>>(); ;
1419 bool retval = false;
1420
1421 //if (((IGPALFileInternal)inputFile).AlreadyTokenized)
1422 //{
1423 // return true;
1424 //}
1425
1426 // Preserve a caller-supplied FirstLineIsColumnHeaders (e.g. via the converter-level
1427 // WithFirstLineHasColumnNames) as a fallback when the GPALFile itself doesn't specify
1428 // FirstLineIsColumnNames for this file index.
1429 bool callerFirstLineIsColumnHeaders = myConverterSettings?.InputFirstLineIsColumnHeaders ?? false;
1430
1431 // The output format is already known by the time TokenizeFile runs, so for delimited
1432 // input we can avoid holding a fully-populated dictionary AND a re-derived delimited
1433 // string AND a grid all in memory at once - only one of those representations is
1434 // actually consumed downstream depending on the output format.
1435 DataFormat outDataFormat = myConverterSettings?.OutDataFormat ?? DataFormat.NOTSET;
1436 bool outputNeedsDictionary = outDataFormat == DataFormat.JSON
1437 || outDataFormat == DataFormat.XML
1438 || outDataFormat == DataFormat.YAML
1439 || outDataFormat == DataFormat.CLASS
1440 || outDataFormat == DataFormat.DICTIONARY
1441 || outDataFormat == DataFormat.DATABASE;
1442
1443 ((IGPALFileInternal)inputFile).FileDictionary.Clear();
1444 foreach (string filename in inputFile.Filenames)
1445 {
1446 DataFormat inDataFormat = ConverterHelper.GetDataFormatFromExtension(filename);
1447 bool inputIsDelimited = ConverterHelper.IsDelimitedDataFormat(inDataFormat);
1448
1449 myConverterSettings = new ConverterSettings
1450 {
1451 InDelimiter = ((IGPALFileInternal)inputFile).Delimiter.Count() > fileIdx && ((IGPALFileInternal)inputFile).Delimiter[fileIdx].HasValue
1452 ? ((IGPALFileInternal)inputFile).Delimiter[fileIdx].Value
1453 : (char?)null,
1454 FieldsEnclosedInQuotes = ((IGPALFileInternal)inputFile).FieldsEnclosedInQuotes.Count() > fileIdx
1455 ? ((IGPALFileInternal)inputFile).FieldsEnclosedInQuotes[fileIdx]
1456 : false,
1457 FirstLineIsColumnHeaders = ((IGPALFileInternal)inputFile).FirstLineIsColumnNames.Count() > fileIdx
1458 ? ((IGPALFileInternal)inputFile).FirstLineIsColumnNames[fileIdx]
1459 : callerFirstLineIsColumnHeaders,
1460 IgnoreFirstLineColumnHeaders = ((IGPALFileInternal)inputFile).IgnoreFirstLineColumnNames.Count() > fileIdx
1461 ? ((IGPALFileInternal)inputFile).IgnoreFirstLineColumnNames[fileIdx]
1462 : false,
1463 ColumnNames = inputIsDelimited ? ColumnNamesForFile(inputFile, fileIdx) : new GPALGrid<string>(),
1464 ExcelSheetNames = ((IGPALFileInternal)inputFile).FileSettings.ExcelSheetNames,
1465 ExcelRowsPerSheet = ((IGPALFileInternal)inputFile).FileSettings.ExcelRowsPerSheet,
1466 InputFile = inputFile,
1467 };
1468 if (fileIdx < ((IGPALFileInternal)inputFile).FileSettings.Delimiter.Count())
1469 {
1470 ((IGPALFileInternal)inputFile).FileSettings.Delimiter[fileIdx] = ConverterHelper.GetDelimiterFromFormat(inDataFormat);
1471 }
1472 else
1473 {
1474 ((IGPALFileInternal)inputFile).FileSettings.Delimiter.Add(ConverterHelper.GetDelimiterFromFormat(inDataFormat));
1475 }
1476
1477 try
1478 {
1479 string directory = Path.GetDirectoryName(filename);
1480 string filenamePart = Path.GetFileName(filename);
1481 foreach (string filename2 in Directory.GetFiles(string.IsNullOrEmpty(directory) ? "." : directory, filenamePart))
1482 {
1483 using (input = new StreamReader(filename2))
1484 {
1485 input.BaseStream.Position = 0;
1486
1487 // a format with no case here was never read, and saying it tokenized set
1488 // AlreadyTokenized, after which every later read skipped it for good
1489 bool formatHandled = true;
1490
1491 switch (inDataFormat)
1492 {
1493 case DataFormat.CARET:
1494 case DataFormat.CSV:
1495 case DataFormat.PIPE:
1496 case DataFormat.PRN:
1497 case DataFormat.TAB:
1498 var dictionary = ConverterHelper.ConvertDelimitedToDictionary(myConverterSettings, input, filename2);
1499 ((IGPALFileInternal)inputFile).FileDictionary.Add(dictionary);
1500
1501 // this file's dictionary on its own. FileDictionary accumulates every file read so
1502 // far and the converter reads entry zero, so handing it the whole list converted
1503 // the first file again on every later file's pass
1504 myConverterSettings.InputDictionary = new List<Dictionary<object, dynamic>> { dictionary };
1505
1506 if (outputNeedsDictionary)
1507 {
1508 // Output consumes the dictionary directly - skip re-deriving the
1509 // delimited string/grid representations entirely.
1510 ((IGPALFileInternal)inputFile).FileData.Add(string.Empty);
1511 ((IGPALFileInternal)inputFile).FileGrid.Add(GPAL.Grid.ToGPALObject());
1512 }
1513 else
1514 {
1515 dynamic delimitedData = ConverterHelper.ConvertInputDictionaryToDelimitedAndGrid(myConverterSettings, out outGrid);
1516 ((IGPALFileInternal)inputFile).FileData.Add(delimitedData);
1517 ((IGPALFileInternal)inputFile).FileGrid.Add(outGrid);
1518
1519 // The data/grid were derived from the dictionary and the output
1520 // doesn't need the dictionary itself - free its contents now.
1521 dictionary.Clear();
1522 }
1523 break;
1524
1525 case DataFormat.LOG:
1526 // lines of text, so the grid is one column and one row per line. there is
1527 // nothing to split on; a workflow that wants structure saves it out to a
1528 // format that has some
1529 string logText = input.ReadToEnd();
1530 input.BaseStream.Position = 0;
1531
1532 IGPALGrid<string> logGrid = GPAL.GridForType<string>();
1533 var logRecords = new Dictionary<object, dynamic>();
1534 int logLineIdx = 0;
1535
1536 // the newline a text file ends with is a terminator, not an empty last line,
1537 // so it is taken off first. blank lines inside the file are kept, since a log
1538 // that has them has them
1539 string logLines = logText.TrimEnd(Environment.NewLine.ToCharArray());
1540
1541 if (false == string.IsNullOrEmpty(logLines))
1542 foreach (string logLine in logLines.Split(new[] { Environment.NewLine, "\n" }, StringSplitOptions.None))
1543 {
1544 logGrid.AddRow(new List<string> { logLine });
1545 logRecords[$"GPALKEY{logLineIdx++:D4}"] = logLine;
1546 }
1547
1548 ((IGPALFileInternal)inputFile).FileData.Add(logText);
1549 ((IGPALFileInternal)inputFile).FileGrid.Add(logGrid);
1550 ((IGPALFileInternal)inputFile).FileDictionary.Add(logRecords);
1551 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1552 break;
1553
1554 case DataFormat.JSON:
1555 string fileString = input.ReadToEnd();
1556 input.BaseStream.Position = 0;
1557
1558 dynamic jsonObject = JsonConvert.DeserializeObject<dynamic>(fileString, new JsonConverter[] { new CustomJsonConverter() });
1559
1560 ((IGPALFileInternal)inputFile).FileData.Add(fileString);
1561
1562 var records = new Dictionary<object, dynamic>();
1563 var gridRows = new List<List<string>>();
1564
1565 // Helper to recursively convert any array (IEnumerable<dynamic>) to surrogate-keyed dict
1566 dynamic ConvertArrayToSurrogateDict(IEnumerable<dynamic> array, int startIndex = 0)
1567 {
1568 var surrogateDict = new Dictionary<object, dynamic>();
1569 int idx = startIndex;
1570 foreach (dynamic item in array)
1571 {
1572 surrogateDict[$"GPALKEY{idx:D4}"] = item;
1573 idx++;
1574 }
1575 return surrogateDict;
1576 }
1577
1578 // Recursively walk the structure and replace all arrays with surrogate dicts
1579 dynamic ReplaceArrays(dynamic value)
1580 {
1581 if (value is IEnumerable<dynamic> enumerable && !(value is string))
1582 {
1583 var items = Enumerable.ToList<dynamic>(enumerable);
1584
1585 // A dictionary with a complex (non-string) key serializes as an array of
1586 // {"Key": ..., "Value": ...} objects instead of a plain JSON object (see
1587 // NormalizeForCleanJson) - a complex key can't be a JSON object key at all,
1588 // and this is the standard convention (matching KeyValuePair<K,V>'s own
1589 // property names) most serializers use for this case, rather than an
1590 // invented GPAL-specific marker leaking into the file. Detect that shape
1591 // and rebuild a real dictionary here (the key may itself be a nested dict,
1592 // same as YAML's explicit-key mapping - CreateObjectFromDictionary already
1593 // reconstructs that correctly) instead of wrapping it as a plain
1594 // GPALKEY-indexed surrogate. Note: a genuine two-property {Key, Value}
1595 // data object (e.g. a real KeyValuePair-shaped list element) would also
1596 // match this shape - an acceptable, low-probability ambiguity shared by
1597 // every serializer that uses this same convention.
1598 bool looksLikePairsArray = items.Count > 0 && items.All(it =>
1599 it is IDictionary d && d.Count == 2
1600 && d.Contains("Key") && d.Contains("Value"));
1601 if (looksLikePairsArray)
1602 {
1603 var pairsDict = new Dictionary<object, dynamic>();
1604 foreach (IDictionary pairObj in items)
1605 {
1606 dynamic pairKey = ReplaceArrays(pairObj["Key"]);
1607 dynamic pairValue = ReplaceArrays(pairObj["Value"]);
1608 pairsDict[pairKey] = pairValue;
1609 }
1610 return pairsDict;
1611 }
1612
1613 // It's an array/list
1614 return ConvertArrayToSurrogateDict(items);
1615 }
1616 else if (value is IDictionary<string, dynamic> dict)
1617 {
1618 // It's an object - recurse into values
1619 var newDict = new Dictionary<object, dynamic>();
1620 foreach (var kvp in dict)
1621 {
1622 newDict[kvp.Key] = ReplaceArrays(kvp.Value);
1623 }
1624 return newDict;
1625 }
1626 else
1627 {
1628 // Primitive - return as-is
1629 return value;
1630 }
1631 }
1632
1633 dynamic processedRoot = ReplaceArrays(jsonObject);
1634
1635 // If top-level is array, wrap as surrogate dict for FileDictionary
1636 if (processedRoot is IDictionary && ConverterHelper.IsArrayLike((IDictionary)processedRoot))
1637 {
1638 records = (Dictionary<object, dynamic>)processedRoot;
1639 }
1640 else
1641 {
1642 // Single object or other - wrap under a key or just store
1643 records["root"] = processedRoot;
1644 }
1645
1646 // Grid building remains the same: iterate top-level records (now surrogate-keyed)
1647 foreach (KeyValuePair<object, dynamic> pair in records.OrderBy(p => p.Key.ToString()))
1648 {
1649 if (pair.Value is IDictionary rowDict)
1650 {
1651 var row = new List<string>();
1652 foreach (dynamic kvp in rowDict)
1653 {
1654 if (kvp.Key != null)
1655 {
1656 string key = kvp.Key.ToString();
1657 if (key.StartsWith("GPALKEY")) continue; // skip surrogates in grid
1658 var processedValue = ProcessDictionaryValue(kvp.Value, null, null, key);
1659 if (!(kvp.Value is IDictionary) && !(kvp.Value is IList<object>))
1660 {
1661 row.Add(FlattenValueForGrid(processedValue));
1662 }
1663 }
1664 }
1665 if (row.Count > 0)
1666 {
1667 gridRows.Add(row);
1668 }
1669 }
1670 }
1671
1672 ((IGPALFileInternal)inputFile).FileDictionary.Add(records);
1673
1674 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"JSON FileDictionary count: [{records.Count}]", null, GPALObjectType.Other);
1675
1676 var gridRow = GPAL.Grid.ToGPALObject();
1677 foreach (var row in gridRows)
1678 {
1679 gridRow.AddRow(row);
1680 }
1681
1682 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1683 ((IGPALFileInternal)inputFile).FileGrid.Add(gridRow);
1684 break;
1685
1686 case DataFormat.XLSX:
1687 IGPALGrid<string> grid = ExcelHelper.TokenizeExcelFile(filename2);
1688 List<string> colNames = new List<string>();
1689 List<Dictionary<object, dynamic>> returnList = new List<Dictionary<object, dynamic>>();
1690
1691 ((IGPALFileInternal)inputFile).FileGrid.Add((IGPALGrid<string>)grid.Clone());
1692
1693 // find out how many worksheets we have and find out the number of rows to ascertain when the grid data is another sheet with different column names
1694 using (var workbook = new XLWorkbook(filename))
1695 {
1696 List<int> rowsPerSheet = new List<int>();
1697 List<string> sheetName = new List<string>();
1698
1699 foreach (IXLWorksheet worksheet in workbook.Worksheets)
1700 {
1701 int lastRowNumber = worksheet.LastRowUsed()?.RowNumber() ?? 0;
1702 // If there are blank rows after the last used row, use the sheet's defined range
1703 int totalRows = Math.Max(lastRowNumber, worksheet.RangeUsed()?.LastRow().RowNumber() ?? 0);
1704 // Or even simpler – just take the maximum row index that exists:
1705 totalRows = worksheet.Rows().LastOrDefault()?.RowNumber() ?? 0;
1706
1707 rowsPerSheet.Add(totalRows);
1708 sheetName.Add(worksheet.Name);
1709 }
1710
1711 ((IGPALFileInternal)inputFile).FileSettings.ExcelRowsPerSheet.AddRow(rowsPerSheet);
1712 ((IGPALFileInternal)inputFile).FileSettings.ExcelSheetNames.AddRow(sheetName);
1713
1714 int columnHeaders = 0;
1715 foreach (int rowCnt in rowsPerSheet)
1716 {
1717 if (myConverterSettings.FirstLineIsColumnHeaders)
1718 {
1719 colNames = grid[columnHeaders];
1720 }
1721 else
1722 {
1723 int colIdx = 0;
1724 foreach (string columnName in grid[columnHeaders].Select(v => (string)v))
1725 {
1726 colNames.Add($"Column{colIdx++}");
1727 }
1728 }
1729
1730 myConverterSettings.ColumnNames.AddRow(colNames);
1731
1732 if (0 == ((IGPALFileInternal)inputFile).FileSettings.ColumnList.Count())
1733 ((IGPALFileInternal)inputFile).FileSettings.ColumnList.AddRow(colNames);
1734
1735 int rowIdx = 0;
1736
1737 foreach (Dictionary<object, dynamic> row in ConverterHelper.ConvertGridToDictionary(colNames, grid, columnHeaders, rowCnt))
1738 {
1739 if (true == myConverterSettings.FirstLineIsColumnHeaders && 0 == rowIdx++)
1740 continue;
1741
1742 returnList.Add(row);
1743 }
1744
1745 columnHeaders += rowCnt;
1746 }
1747
1748 // now delete the column headers if denoted in settings
1749 columnHeaders = 0;
1750 int iteration = 0;
1751 foreach (int rowCnt in rowsPerSheet)
1752 {
1753 if (myConverterSettings.FirstLineIsColumnHeaders)
1754 {
1755 grid.DeleteRow(0);
1756 }
1757 columnHeaders = rowCnt - iteration++; // we have one less row than we counted in the grid for each iteration
1758 }
1759 }
1760
1761 ((IGPALFileInternal)inputFile).FileDictionary = returnList;
1762 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1763 break;
1764
1765 case DataFormat.XML:
1766 xmlDoc.PreserveWhitespace = true;
1767 xmlDoc.Load(input);
1768 input.BaseStream.Position = 0;
1769 var xmlDictionary = ConverterHelper.ConvertXMLToDictionary(xmlDoc);
1770 ((IGPALFileInternal)inputFile).FileDictionary.Add(xmlDictionary);
1771 myConverterSettings.InputDictionary = new List<Dictionary<object, dynamic>> { xmlDictionary };
1772 ((IGPALFileInternal)inputFile).FileData.Add(ConverterHelper.ConvertInputDictionaryToDelimitedAndGrid(myConverterSettings, out outGrid));
1773 ((IGPALFileInternal)inputFile).FileGrid.Add(outGrid);
1774 break;
1775
1776 case DataFormat.YAML:
1777 var deserializer = new DeserializerBuilder()
1778 .WithNamingConvention(CamelCaseNamingConvention.Instance)
1779 .Build();
1780
1781 string yamlText = input.ReadToEnd();
1782 input.BaseStream.Position = 0;
1783 dynamic yamlObject = deserializer.Deserialize<object>(yamlText);
1784 ((IGPALFileInternal)inputFile).FileData.Add(yamlText);
1785
1786 ((IGPALFileInternal)inputFile).FileDictionary.Clear();
1787 ((IGPALFileInternal)inputFile).FileGrid.Clear();
1788
1789 var yamlGridRows = new List<List<string>>();
1790 var processedYamlDict = new Dictionary<dynamic, dynamic>();
1791
1792 if (yamlObject is IDictionary yamlMapping)
1793 {
1794 foreach (var key in yamlMapping.Keys)
1795 {
1796 dynamic processedKey = (key is IDictionary || key is IList)
1797 ? ProcessDictionaryValue(key, yamlGridRows)
1798 : (key?.ToString() ?? "null_key");
1799 processedYamlDict[processedKey] = ProcessDictionaryValue(yamlMapping[key], yamlGridRows);
1800 }
1801 }
1802 else if (yamlObject is IList yamlSequence)
1803 {
1804 // Root is a YAML sequence, not a mapping - synthesize row keys
1805 // (same GPALKEYN convention used for headerless CSV rows).
1806 int rootIdx = 0;
1807 foreach (var item in yamlSequence)
1808 {
1809 processedYamlDict[$"GPALKEY{rootIdx++:D4}"] = ProcessDictionaryValue(item, yamlGridRows);
1810 }
1811 }
1812 else
1813 {
1814 // Root is a bare YAML scalar.
1815 processedYamlDict["GPALKEY0000"] = ProcessDictionaryValue(yamlObject, yamlGridRows);
1816 }
1817
1818 var yamlGridRow = GPAL.Grid.ToGPALObject();
1819 foreach (var row in yamlGridRows)
1820 {
1821 yamlGridRow.AddRow(row);
1822 }
1823
1824 ((IGPALFileInternal)inputFile).FileDictionary.Add(processedYamlDict);
1825 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1826 ((IGPALFileInternal)inputFile).FileGrid.Add(yamlGridRow);
1827 break;
1828
1829 case DataFormat.HTML:
1830 ((IGPALFileInternal)inputFile).FileDictionary = ConverterHelper.ConvertHtmlToDictionary(filename2);
1831 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1832 dynamic inData = ConverterHelper.ConvertInputDictionaryToDelimitedAndGrid(myConverterSettings, out outGrid);
1833 ((IGPALFileInternal)inputFile).FileData.Add(inData);
1834 ((IGPALFileInternal)inputFile).FileGrid.Add(outGrid);
1835 break;
1836
1837 default:
1838 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{inDataFormat}] is not a format GPAL reads, so nothing was tokenized from [{filename}]", inputFile, GPALObjectType.GPALFile);
1839 formatHandled = false;
1840 break;
1841 }
1842
1843 // true when at least one of the filenames was read. a set where the last file is a
1844 // format we do not read still tokenized the ones before it, and their rows are in
1845 // FileGrid, so answering false for the whole set would throw that away
1846 retval = retval || formatHandled;
1847 }
1848 }
1849 }
1850 catch (Exception ex)
1851 {
1852 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Problem tokenizing file [{filename}]. Continuing.", inputFile, GPALObjectType.GPALFile, ex);
1853 }
1854 fileIdx++;
1855 }
1856
1857 if (0 == ((IGPALFileInternal)inputFile).FileSettings.FileGrid.Count)
1858 FileHelper.GetGrid(((IGPALFileInternal)inputFile).FileSettings, out tokens);
1859 else
1860 tokens = ((IGPALFileInternal)inputFile).FileSettings.FileGrid;
1861
1862 ((IGPALFileInternal)inputFile).TokenList = tokens;
1863 ((IGPALFileInternal)inputFile).AlreadyTokenized = retval;
1864
1865 return retval;
1866 }
1867 [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
1868 private static extern int GetShortPathName(string lpszLongPath, StringBuilder lpszShortPath, int cchBuffer);
1869
1877 public static string GetShortPath(string longPath)
1878 {
1879 var sb = new StringBuilder(1024);
1880 int result = GetShortPathName(longPath, sb, sb.Capacity);
1881 return result > 0 ? sb.ToString() : longPath;
1882 }
1883
1890 internal static bool IsWebAddress(string fileName)
1891 {
1892 return true == Uri.TryCreate(fileName, UriKind.Absolute, out Uri uri)
1893 && (Uri.UriSchemeHttp == uri.Scheme || Uri.UriSchemeHttps == uri.Scheme);
1894 }
1895
1896
1908 internal static bool FetchOrDownloadInTab(string url, string localPath, IBrowser browser)
1909 {
1910 UnitOfWork safeUOW = ((Browser.Browser)browser).CurrentUOW;
1911
1912 bool gotIt = FetchToFile(url, localPath, browser);
1913
1914 // a page is saved by reading it, not by waiting for a download that never starts. this is what right
1915 // click Save As does on anything the browser renders, and what reading robots.txt has always done
1916 if (false == gotIt)
1917 gotIt = SaveSourceInTab(url, localPath, browser);
1918
1919 // changes the current unit of work
1920 if (false == gotIt)
1921 gotIt = DownloadInTab(url, localPath, browser);
1922
1923 ((Browser.Browser)browser).CurrentUOW = safeUOW;
1924
1925 return gotIt;
1926 }
1927
1942 internal static bool SaveSourceInTab(string url, string localPath, IBrowser browser)
1943 {
1944 bool saved = false;
1945 Browser.Browser gpalBrowser = (Browser.Browser)browser;
1946 int tabsBefore = gpalBrowser.tabsWeOpened;
1947 string wasOn = browser.CurrentUrl;
1948
1949 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Saving [{url}] as a page to [{localPath}].", browser, GPALObjectType.Browser);
1950
1951 browser.NewTab(url).GetPageSource(out string pageSource);
1952
1953 // the tab is still ours to close only if opening it actually left one. when the url downloaded, GoTo
1954 // has already backed out of the tab the browser disposed, and closing again would take the workflow's
1955 bool tabIsOurs = tabsBefore < gpalBrowser.tabsWeOpened;
1956
1957 if (false == tabIsOurs)
1958 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{url}] is not a page the browser renders, so there is no source to save.", browser, GPALObjectType.Browser);
1959 else if (true == string.IsNullOrEmpty(pageSource))
1960 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{url}] rendered nothing to save as [{localPath}].", browser, GPALObjectType.Browser);
1961 else
1962 {
1963 File.WriteAllText(localPath, pageSource);
1964 saved = true;
1965 }
1966
1967 if (true == tabIsOurs)
1968 {
1969 browser.CloseTab();
1970 gpalBrowser.CurrentUrl = wasOn;
1971 }
1972
1973 return saved;
1974 }
1975
1985 internal static int AllowanceFor(string downloadDirectory, Browser.BrowserSettings browserSettings)
1986 {
1987 int retVal = true == IsFinalizing(downloadDirectory, watchArmed)
1988 ? browserSettings.DownloadTimeoutInSec * FinalizeBudgetMultiplier
1989 : browserSettings.DownloadTimeoutInSec;
1990
1991 return retVal;
1992 }
1993
2005 internal static void DiscardAbandonedDownload(Browser.BrowserSettings browserSettings)
2006 {
2007 foreach (string scratch in watchedScratchFiles)
2008 try
2009 {
2010 if (true == File.Exists(scratch))
2011 {
2012 File.Delete(scratch);
2013 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{Path.GetFileName(scratch)}] was left behind by a download that never finished, removed so it does not name the next one around it", browserSettings.Browser, GPALObjectType.Browser);
2014 }
2015 }
2016 catch (Exception ex)
2017 {
2018 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not remove [{scratch}]", browserSettings.Browser, GPALObjectType.Browser, ex);
2019 }
2020
2021 watchedScratchFiles.Clear();
2022 }
2023
2024 internal const int FinalizeBudgetMultiplier = 6; // the browser's own finish-up is slower than a transfer and is not ours to hurry
2025
2037 internal static bool IsFinalizing(string downloadDirectory, DateTime since)
2038 {
2039 bool retVal = false;
2040
2041 try
2042 {
2043 if (true == Directory.Exists(downloadDirectory))
2044 // the later of the two, for the reason NewestDownloadSince takes it: windows hands a file
2045 // recreated under a name it just used the original's creation time, and a run downloading the
2046 // same file over and over reuses these names constantly. by creation time alone the browser's
2047 // brand new scratch file reads as belonging to the download before it, and the finalize
2048 // allowance is refused to the one download that is actually finalizing
2049 retVal = Directory.EnumerateFiles(downloadDirectory)
2050 .Any(file => true == IsTemporaryDownload(file)
2051 && (File.GetCreationTime(file) >= since || File.GetLastWriteTime(file) >= since));
2052 }
2053 catch (Exception ex)
2054 {
2055 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not look for a download being finished in [{downloadDirectory}]", null, GPALObjectType.None, ex);
2056 }
2057
2058 return retVal;
2059 }
2060
2061 internal static long DownloadProgress(string downloadDirectory, string localPath)
2062 {
2063 long retVal = -1;
2064
2065 try
2066 {
2067 if (true == File.Exists(localPath))
2068 retVal = new FileInfo(localPath).Length;
2069
2070 if (true == Directory.Exists(downloadDirectory))
2071 foreach (string scratch in Directory.EnumerateFiles(downloadDirectory).Where(f => true == IsTemporaryDownload(f)))
2072 retVal = (0 > retVal ? 0 : retVal) + new FileInfo(scratch).Length;
2073 }
2074 catch (Exception ex)
2075 {
2076 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not measure the download in [{downloadDirectory}]", null, GPALObjectType.None, ex);
2077 }
2078
2079 return retVal;
2080 }
2093 internal static bool DownloadInTab(string url, string localPath, IBrowser browser)
2094 {
2095 Browser.BrowserSettings browserSettings = ((Browser.Browser)browser).BrowserSettings;
2096
2097 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloading [{url}] with the browser to [{localPath}].", browser, GPALObjectType.Browser);
2098
2099 // the browser decides which directory a download goes to, not us, so the file lands in the download
2100 // directory first and is moved to localPath afterwards. fetch has the bytes and writes straight to
2101 // localPath, which is why the two paths differ here
2102 string downloadDirectory = GetDefaultDownloadDirectory(browser);
2103 string landsAt = Path.Combine(downloadDirectory, Path.GetFileName(localPath));
2104
2105 // ask for the filename we want. each engine takes it differently: the extension suggests a name
2106 // relative to the download directory, puppeteer takes the directory, selenium takes the full path
2107 if (true == browserSettings.UseOttoMagic)
2108 browserSettings.MagicHelper.WithDownloadFile(Path.GetFileName(localPath));
2109 else if (true == browserSettings.UsePuppeteer)
2110 browserSettings.PuppeteerClient.DownloadTo(downloadDirectory).Execute();
2111 else
2112 BrowserHelper.SetHeadlessDownload(browserSettings, landsAt);
2113
2114 // SetupDownloadWatcher is what leftclickanddownload uses. it watches the directory, waits for the
2115 // .crdownload or .part to stop growing, moves the finished file to landsAt and sets FileDownloaded
2116 browserSettings.FileDownloaded = null;
2117 SetupDownloadWatcher(landsAt, browserSettings);
2118
2119 browser.NewTab(url);
2120
2121 Stopwatch waitingToStart = Stopwatch.StartNew();
2122
2123 // FileDownloaded stays null until the watcher sets it true or false.
2124 // DownloadTimeoutInSec is how long to wait for the download to start, not to finish, so stop the
2125 // clock once a file shows up in the download directory and let the watcher take as long as it needs
2126 long lastSeen = long.MinValue;
2127
2128 while (null == browserSettings.FileDownloaded || false == browserSettings.FileDownloaded)
2129 {
2130 long progress = DownloadProgress(downloadDirectory, landsAt);
2131
2132 if (progress != lastSeen) // it moved, so it is alive and gets the whole budget again
2133 {
2134 lastSeen = progress;
2135 waitingToStart.Restart();
2136 }
2137 else if (waitingToStart.Elapsed.TotalSeconds > AllowanceFor(downloadDirectory, browserSettings))
2138 break; // nothing has happened for the whole allowance: it never started, or it died
2139
2140 Thread.Sleep(100);
2141 }
2142
2143 // we opened the tab so we close it. chromium usually closes it itself, firefox does not
2144 browser.CloseTab();
2145
2146 bool downloaded = true == browserSettings.FileDownloaded;
2147
2148 // move it out of the download directory into the working directory, where WithFileName said the file
2149 // would be. everything downstream, CopyTo and the converter included, is looking at localPath
2150 if (true == downloaded && false == landsAt.Equals(localPath, StringComparison.OrdinalIgnoreCase))
2151 {
2152 if (true == File.Exists(localPath))
2153 File.Delete(localPath);
2154
2155 File.Move(landsAt, localPath);
2156 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{landsAt}] moved to [{localPath}]", browser, GPALObjectType.Browser);
2157 }
2158
2159 if (false == downloaded)
2160 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{url}] did not download to [{downloadDirectory}] within [{browserSettings.DownloadTimeoutInSec}] seconds.", browser, GPALObjectType.Browser);
2161
2162 return downloaded;
2163 }
2187 private static string CrossOriginNote(string url, IBrowser browser)
2188 {
2189 string retVal = "";
2190 string pageUrl = ((Browser.Browser)browser).BrowserSettings.CurrentURL;
2191
2192 if (true == Uri.TryCreate(url, UriKind.Absolute, out Uri wanted)
2193 && true == Uri.TryCreate(pageUrl, UriKind.Absolute, out Uri onNow)
2194 && false == string.Equals(wanted.GetLeftPart(UriPartial.Authority), onNow.GetLeftPart(UriPartial.Authority), StringComparison.OrdinalIgnoreCase))
2195 retVal = $" The page is on [{onNow.GetLeftPart(UriPartial.Authority)}] and the file is on [{wanted.GetLeftPart(UriPartial.Authority)}], so the fetch was cross origin and only works when that server allows it./* Navigating to the file's own site first, or letting the tab download handle it, is what gets past that.*/";
2196
2197 return retVal;
2198 }
2199
2200 internal static bool FetchToFile(string url, string localPath, IBrowser browser)
2201 {
2202 bool downloaded = false;
2203 string[] headers = true == File.Exists(localPath)
2204 ? new string[] { "If-Modified-Since", File.GetLastWriteTimeUtc(localPath).ToString("R") }
2205 : null;
2206
2207 byte[] bytes = BrowserHelper.FetchBytes((Browser.Browser)browser, url, headers, out string lastModified);
2208
2209 if (304 == browser.ServerResponseCode)
2210 {
2211 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{url}] has not changed, using [{localPath}].", browser, GPALObjectType.Browser);
2212 downloaded = true;
2213 }
2214 else if (null == bytes)
2215 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{url}] returned no content to save as [{localPath}], HTTP [{browser.ServerResponseCode}].{CrossOriginNote(url, browser)}", browser, GPALObjectType.Browser);
2216 else if (200 > browser.ServerResponseCode || 300 <= browser.ServerResponseCode)
2217 // the body is whatever the site sends with a refusal, an error page or a json message, and saving
2218 // that as the file would leave something that looks downloaded but is not the file
2219 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{url}] returned HTTP [{browser.ServerResponseCode}], nothing saved to [{localPath}].", browser, GPALObjectType.Browser);
2220 else if (0 == bytes.Length)
2221 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{url}] returned an empty file, nothing saved to [{localPath}].", browser, GPALObjectType.Browser);
2222 else
2223 {
2224 File.WriteAllBytes(localPath, bytes);
2225
2226 // stamp it with the server's own modified time, so next run's question is about the content
2227 // rather than about when we happened to fetch it
2228 if (true == DateTime.TryParse(lastModified, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal, out DateTime modified))
2229 File.SetLastWriteTimeUtc(localPath, modified);
2230
2231 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloaded [{url}] with the browser to [{localPath}].", browser, GPALObjectType.Browser);
2232
2233 downloaded = true;
2234 }
2235
2236 return downloaded;
2237 }
2238
2245 public static string EnsureDirectoryEndsWithBackslash(string directoryPath)
2246 {
2247 // Check if the directory path already ends with '\'
2248 if (!directoryPath.EndsWith("\\"))
2249 {
2250 // If not, add '\'
2251 directoryPath += "\\";
2252 }
2253 return directoryPath;
2254 }
2255
2256 }
2257}
2258
Browser object that contains the fluent methods to create your Browser workflow. Instatiated using G...
Definition Browser.cs:68
static string GetUserAgentString(BrowserType browserType)
Get chrome or edge useragent string based upon what is installed on this system This looks up the ver...
File-side plumbing behind the fluent chain: writing a unit of work's data out in a delimited format,...
Definition FileHelper.cs:55
static string GetShortPath(string longPath)
Returns the 8.3 short form of a path, for the places that still cannot cope with spaces or a long pat...
static string GetEdgeDefaultDownloadDirectory(string profilePath=null)
Reads Edge's download.default_directory out of the profile's Preferences file. Edge is Chromium,...
static string GetFirefoxDefaultDownloadDirectory(string profilePath=null)
Reads Firefox's browser.download.dir out of the profile's prefs.js. Firefox keeps no JSON preferences...
static string EnsureDirectoryEndsWithBackslash(string directoryPath)
Adds a trailing backslash to a directory path when it does not already have one, so the path can be c...
static string GetChromeDefaultDownloadDirectory(string profilePath=null)
Reads Chrome's download.default_directory out of the profile's Preferences file. Falls back to the Wi...
static string GetDefaultDownloadDirectory(IBrowser browser)
Returns the download directory this browser will actually use, read out of its own configuration rath...
Pseudo element used in Applications and Browser workflows for image matching and unified automation....
string TagName
HTML tag name of the element.
string GetAttribute(string attributeName)
Gets an attribute value with fallback to internal dictionary.
GPAL File object instantied with GPAL.File Used to load tokens into a GPALGrid [rows/columns].
Definition GPALFile.cs:36
List< string > Filenames
Get the list of filenames.
Definition GPALFile.cs:540
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static IAllowGridActions< string > Grid
New GPALGrid<string></string> (rows/columns).
Definition GPAL.cs:532
static void PublishSimpleEvent(GPALEventType gPALEventType, string msg, dynamic gPALObject=null, Enums.GPALObjectType gPALObjectType=GPALObjectType.None, Exception ex=null)
Publish a message to either the information channel or exception channel (if exception passed in) Pub...
Definition GPAL.cs:2568
Everything revolves around the Unit of Work. A Unit of Work is defined as one or more selectors betw...
Definition UnitOfWork.cs:39