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 }
297 public static string SaveToDelimited(UnitOfWork currentUOW, string filename, char delim, bool appendToFile, bool DeleteFileBeforeDownload)
298 {
299 List<Selector> selectors = currentUOW.WithSelectorList; // BUG: with savefrom grid, this is not the correct selectorlist, this may give bogus results
300 int colCnt = 0;
301 string fullFilePath = CleanUpFileDestination(filename, out string newFilename, DeleteFileBeforeDownload : false);
302
303 if (0 == currentUOW.HeaderList.Count)
304 {
305 foreach (Selector selector in selectors)
306 {
307 // CAVEAT: BUG: what if the fist selector name is null but after that are not?
308 if (null != selector.Name)
309 currentUOW.HeaderList.Add(selector.Name);
310 }
311 }
312 else if (selectors.Count > currentUOW.HeaderList.Count) // not enough headers for the output, so add the missing headers
313 {
314 for (int idx = currentUOW.HeaderList.Count; idx < selectors.Count; idx++)
315 currentUOW.HeaderList.Add(selectors[idx].Name);
316 }
317 else if (selectors.Count < currentUOW.HeaderList.Count)
318 {
319 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Too many headers, not all will be output. This may not be correct if you are saving your own grid.", currentUOW, GPALObjectType.UnitOfWork);
320 }
321
322 if (false == appendToFile && true == System.IO.File.Exists(fullFilePath))
323 {
324 System.IO.File.Delete(fullFilePath);
325 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Asking to save new to existing file [{fullFilePath}], deleting file.", currentUOW, GPALObjectType.UnitOfWork);
326 }
327 else if (true == appendToFile && false == System.IO.File.Exists(fullFilePath)) // asking to append to a file that does not exist, really doing a save, output headers
328 {
329 appendToFile = false;
330 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Asking to append to non-existent file [{fullFilePath}], creating new file with headers.", currentUOW, GPALObjectType.UnitOfWork);
331 }
332
333 StreamWriter csv;
334 using (csv = System.IO.File.AppendText(fullFilePath))
335 {
336 StringBuilder tmpRow = new StringBuilder();
337 if (false == appendToFile)
338 if (0 < currentUOW.HeaderList.Count)
339 {
340 foreach (string header in currentUOW.HeaderList)
341 {
342 colCnt++;
343 tmpRow.Append(header);
344 if (colCnt < currentUOW.ColCount)
345 tmpRow.Append(delim);
346 }
347 csv.WriteLine(tmpRow);
348 }
349
350 foreach (List<string> row in currentUOW.RetGrid)
351 {
352 colCnt = 0;
353 tmpRow.Clear();
354 foreach (string col in row)
355 {
356 colCnt++;
357 tmpRow.Append(col);
358 if (colCnt < currentUOW.ColCount)
359 tmpRow.Append(delim);
360 }
361 csv.WriteLine(tmpRow);
362 }
363 }
364
365 csv.Close();
366 csv.Dispose();
367 return fullFilePath;
368 }
369
381 internal static bool DownloadToFile(GPALElement webElement, string filename, string downloadUrl = null)
382 {
383 bool downloaded = false;
384 string url = downloadUrl ?? GetDownloadUrl(webElement);
385
386 try
387 {
388 using (System.Net.Http.HttpClient client = new System.Net.Http.HttpClient())
389 {
390 System.Net.Http.HttpRequestMessage request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, url);
391
392 // present ourselves as the browser would. plenty of sites refuse a request carrying no user
393 // agent at all, and being refused for that reason is not worth falling back to a whole browser
394 // over. Same thing CheckRobotsTxt does before it resorts to opening a tab.
395 // Chrome when nothing was said, since a GPALFile need not have a browser to take a type from
396 request.Headers.TryAddWithoutValidation("User-Agent",
397 true == string.IsNullOrWhiteSpace(GPAL.GPALSettings.UserAgent)
398 ? MagicHelper.GetUserAgentString(BrowserType.Chrome)
399 : GPAL.GPALSettings.UserAgent);
400
401 if (true == File.Exists(filename))
402 request.Headers.IfModifiedSince = File.GetLastWriteTimeUtc(filename);
403
404 System.Net.Http.HttpResponseMessage response = client.SendAsync(request).GetAwaiter().GetResult();
405
406 if (System.Net.HttpStatusCode.NotModified == response.StatusCode)
407 {
408 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{url}] has not changed, using [{filename}].", null, GPALObjectType.None);
409 downloaded = true;
410 }
411 else if (false == response.IsSuccessStatusCode)
412 {
413 // not fatal on its own. a 401 or 403 usually means the file is behind a session we do not
414 // have, which is what the browser fallback is for
415 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{url}] returned HTTP [{(int)response.StatusCode}].", null, GPALObjectType.None);
416 }
417 else
418 {
419 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloading [{url}] to [{filename}].", null, GPALObjectType.None);
420 File.WriteAllBytes(filename, response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult());
421
422 // stamp it with the server's own modified time, so next run's question is about the content
423 // rather than about when we happened to fetch it
424 if (null != response.Content.Headers.LastModified)
425 File.SetLastWriteTimeUtc(filename, response.Content.Headers.LastModified.Value.UtcDateTime);
426
427 // nothing to wait for. WriteAllBytes returned, so the bytes are on disk and the file is
428 // whole. watching its size settle is for a browser writing in the background, and here it
429 // only confirmed a write we had already finished
430 downloaded = true;
431 }
432 }
433 }
434 catch (Exception ex)
435 {
436 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{url}] failed to save to [{filename}].", null, GPALObjectType.None, ex);
437 }
438
439 return downloaded;
440 }
446 internal static string GetDownloadUrl(GPALElement element)
447 {
448 // Check the type of element or its attributes to determine the appropriate URL for downloading
449 string downloadUrl = null;
450
451 // If the element is an <a> (anchor) tag, get the href attribute value
452 if (element.TagName.ToLower() == "a")
453 {
454 downloadUrl = element.GetAttribute("href");
455 }
456 // If the element is an <img> (image) tag, get the src attribute value
457 else if (element.TagName.ToLower() == "img")
458 {
459 downloadUrl = element.GetAttribute("src");
460 }
461 // If the element is a <script> tag, get the src attribute value
462 else if (element.TagName.ToLower() == "script")
463 {
464 downloadUrl = element.GetAttribute("src");
465 }
466 // If the element is a <link> tag, get the href attribute value
467 else if (element.TagName.ToLower() == "link")
468 {
469 downloadUrl = element.GetAttribute("href");
470 }
471 // If the element is an <object> tag, get the data attribute value
472 else if (element.TagName.ToLower() == "object")
473 {
474 downloadUrl = element.GetAttribute("data");
475 }
476 // If the element is an <embed> tag, get the src attribute value
477 else if (element.TagName.ToLower() == "embed")
478 {
479 downloadUrl = element.GetAttribute("src");
480 }
481 // If the element is a <frame> or <iframe> tag, get the src attribute value
482 else if (element.TagName.ToLower() == "frame" || element.TagName.ToLower() == "iframe")
483 {
484 downloadUrl = element.GetAttribute("src");
485 }
486 // If the element is an <audio> tag, get the src attribute value
487 else if (element.TagName.ToLower() == "audio")
488 {
489 downloadUrl = element.GetAttribute("src");
490 }
491 // If the element is a <video> tag, get the src attribute value
492 else if (element.TagName.ToLower() == "video")
493 {
494 downloadUrl = element.GetAttribute("src");
495 }
496 // Add more conditions for other types of elements as needed...
497
498 return downloadUrl;
499 }
500 internal static FileSystemWatcher watcher;
501 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
502 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
503 // NOTE: BUG: CAVEAT: this never worked, maybe cause of permissions on the 'special' download folder?
504 internal static string SetupDownloadWatcher(string fullFilePathToSave, Browser.BrowserSettings browserSettings)
505 {
506 // chrome and edge are told to download straight into the destination directory before the click, so
507 // that is where to watch. the two that cannot be told go to the browser's own download directory and
508 // handleFile moves the file from there: ottomagic because the extension can name a download but not
509 // place it, firefox because it has no cdp and only reads its download directory at startup
510 bool downloadsWhereItLikes = true == browserSettings.UseOttoMagic
511 || BrowserType.FireFox == browserSettings.BrowserType;
512 string downloadDirectory = true == downloadsWhereItLikes
513 ? FileHelper.GetDefaultDownloadDirectory(browserSettings.Browser)
514 : Path.GetDirectoryName(fullFilePathToSave) ?? FileHelper.GetDefaultDownloadDirectory(browserSettings.Browser);
515
516 string fileName = Path.GetFileName(fullFilePathToSave);
517 string actualDownloadedFilename = null;
518
519 watchArmed = DateTime.Now;
520 watchedScratchFiles.Clear();
521
522 watcher = new FileSystemWatcher(downloadDirectory);
523 watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes;
524 watcher.Filter = "*.*";
525 watcher.EnableRaisingEvents = true;
526
527 // Add Created (often fires for temp file start)
528 watcher.Created += (sender, e) => handleFile(sender, e, downloadDirectory, fullFilePathToSave);
529 watcher.Changed += (sender, e) => handleFile(sender, e, downloadDirectory, fullFilePathToSave);
530 watcher.Renamed += (sender, e) => handleFile(sender, e, downloadDirectory, fullFilePathToSave);
531
532 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Watching [{downloadDirectory}] for [{fileName}] w/timeout [{browserSettings.DownloadTimeoutInSec}] secs", null, GPALObjectType.None);
533 System.Threading.Thread.Sleep(1_000);
534
535 return downloadDirectory;
536
537 void handleFile(object sender, FileSystemEventArgs e, string downloaddirectory, string fullFilePathToSave)
538 {
539 try
540 {
541 // a browser downloads to a temporary name and renames it to the real one when it finishes, and
542 // the name it lands under is the site's, not the temporary one with its suffix taken off. the
543 // rename is the only event that carries where the file actually went
544 if (e is RenamedEventArgs renamed
545 && (true == renamed.OldName.EndsWith(".crdownload") || true == renamed.OldName.EndsWith(".part")))
546 {
547 actualDownloadedFilename = WaitForDownloadToFinish(Path.Combine(downloaddirectory, renamed.Name), browserSettings.DownloadTimeoutInSec);
548 browserSettings.FileDownloaded = false == string.IsNullOrEmpty(actualDownloadedFilename);
549
550 if (true == browserSettings.FileDownloaded)
551 {
552 File.Move(actualDownloadedFilename, fullFilePathToSave);
553 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{actualDownloadedFilename}] moved to [{fullFilePathToSave}]", null, GPALObjectType.None);
554 }
555 else
556 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{renamed.OldName}] was renamed to [{renamed.Name}] but it never settled", null, GPALObjectType.None);
557
558 // only once it is done. these events keep arriving and a later one can resolve what this
559 // one could not, so standing the watcher down on a miss is what makes a miss permanent
560 if (true == browserSettings.FileDownloaded)
561 ((FileSystemWatcher)sender).Dispose();
562
563 return;
564 }
565
566 if (e.Name.EndsWith(".crdownload") || e.Name.EndsWith(".part"))
567 {
568 string filename = e.Name.Replace(".crdownload", "").Replace(".part", "");
569 string fullSavedFilePath = Path.Combine(downloaddirectory, filename); // Safer path building
570
571 string pattern = @"^downloads(?:\.htm|\s*\‍(\d+\‍)\.htm|\.htm\s*\‍(\d+\‍))$";
572 if (Regex.IsMatch(filename, pattern, RegexOptions.IgnoreCase))
573 {
574 return;
575 }
576
577 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Download started for [{e.Name}] waiting on expected [{fullSavedFilePath}]", null, GPALObjectType.None);
578 watchedScratchFiles.Add(Path.Combine(downloaddirectory, e.Name));
579
580 // disable events temporarily to avoid duplicate calls during wait
581 // watcher.EnableRaisingEvents = false;
582
583 // first check and wait for the crdownload file to finish, which might finish before we even call, so it never finds it...
584 if (true == File.Exists(Path.Combine(downloaddirectory, e.Name)))
585 actualDownloadedFilename = WaitForDownloadToFinish(Path.Combine(downloaddirectory, e.Name), browserSettings.DownloadTimeoutInSec);
586
587 // the scratch file is never the download. WaitForDownloadToFinish hands its name back to
588 // say it watched one and it has gone, which is the browser renaming it to the real name,
589 // so drop it here and let the real name be found below. moving it is how a download that
590 // stalled with a steady size, or one still being written, ended up saved as the answer
591 if (true == IsTemporaryDownload(actualDownloadedFilename))
592 actualDownloadedFilename = null;
593
594 // the destination itself. when the filename was set for this download the browser renames
595 // the temporary file straight to it, so it is already where it was asked to go
596 if (true == File.Exists(fullFilePathToSave))
597 actualDownloadedFilename = WaitForDownloadToFinish(fullFilePathToSave, browserSettings.DownloadTimeoutInSec);
598
599 // and failing that the temporary name with its suffix taken off, for a browser that lands
600 // it there. only when nothing above answered - asking regardless threw away what we had
601 if (true == string.IsNullOrEmpty(actualDownloadedFilename))
602 actualDownloadedFilename = WaitForDownloadToFinish(fullSavedFilePath, browserSettings.DownloadTimeoutInSec);
603
604 browserSettings.FileDownloaded = !string.IsNullOrEmpty(actualDownloadedFilename);
605
606 if (true == browserSettings.FileDownloaded)
607 {
608 // the browser was told where to put it and did, so there is nothing to move and
609 // File.Move onto itself only throws
610 if (true == fullFilePathToSave.Equals(actualDownloadedFilename, StringComparison.OrdinalIgnoreCase))
611 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{fullFilePathToSave}] downloaded straight to where it was asked to go", null, GPALObjectType.None);
612 else
613 {
614 File.Move(actualDownloadedFilename, fullFilePathToSave);
615 if (File.Exists(actualDownloadedFilename))
616 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Unable to move [{actualDownloadedFilename}] to [{fullFilePathToSave}]", null, GPALObjectType.None);
617 else
618 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{actualDownloadedFilename}] moved to [{fullFilePathToSave}]", null, GPALObjectType.None);
619 }
620 }
621 else
622 {
623 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"WaitForDownloadToFinish returned null for [{fullSavedFilePath}] - check timeout or stability", null, GPALObjectType.None);
624 }
625
626 // as above: a browser still finalizing has not renamed anything yet, and the event that
627 // says it did is the one this would have thrown away
628 if (true == browserSettings.FileDownloaded)
629 ((FileSystemWatcher)sender).Dispose();
630 }
631 }
632 catch (Exception ex)
633 {
634 // the watcher stays up and the flag stays as it was. these events arrive many times over one
635 // download and any of them can catch the directory mid-change, so this is one event going
636 // wrong rather than the download failing. shutting the watcher down here meant the download
637 // could never be reported, and answering false here would end the wait on the same mistake
638 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Error for [{fullFilePathToSave}]", null, GPALObjectType.None, ex);
639 }
640 }
641 }
642
649 internal const int DownloadSizeSampleMs = 500; // long enough that a file still being written is seen to grow
650
656 internal static bool IsTemporaryDownload(string path)
657 {
658 bool retVal = true == path?.EndsWith(".crdownload") || true == path?.EndsWith(".part");
659
660 return retVal;
661 }
662
675 internal static string NewestDownloadSince(string directory, DateTime since)
676 {
677 string retVal = null;
678 DateTime newest = DateTime.MinValue;
679
680 foreach (string candidate in Directory.EnumerateFiles(directory))
681 if (false == IsTemporaryDownload(candidate))
682 {
683 // the later of the two, because either one on its own can read older than the file is. a
684 // fetched file is stamped with the server's Last-Modified and its write time can be years
685 // old, and windows hands a file recreated under a name it just used the original's creation
686 // time. both being stale at once is the one thing that does not happen
687 DateTime written = File.GetLastWriteTime(candidate);
688 DateTime created = File.GetCreationTime(candidate);
689 DateTime arrived = created > written ? created : written;
690
691 if (arrived >= since && arrived > newest)
692 {
693 newest = arrived;
694 retVal = candidate;
695 }
696 }
697
698 return retVal;
699 }
700
701 internal static string WaitForDownloadToFinish(string filePath, int downloadTimeoutInSec)
702 {
703 long size = 0, newSize = 0;
704 string downloadedFilename;
705 bool fileStarted = false;
706
707 newSize = WaitForFileToDownload(filePath, downloadTimeoutInSec, size, out downloadedFilename); // -1 return if timed out waiting for file to exist (not completed)
708
709 // KLUDGE: file showed up but is zero bytes, this could be correct, but let's give an extra second and wait
710 // 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
711 // our file checks can run very quuckly
712 if (0 == newSize)
713 System.Threading.Thread.Sleep(1_000);
714 else if (-1 == newSize)
715 {
716 if (false == filePath.EndsWith(".crdownload") && false == filePath.EndsWith(".part"))
717 return string.Empty;
718 else
719 return filePath;
720 }
721
722 size = newSize;
723
724 // NOTE: this is prolly really overkill, essentially checking multiple times for completion
725 do
726 {
727 if (0 <= (newSize = WaitForFileToDownload(filePath, downloadTimeoutInSec, newSize, out downloadedFilename)))
728 {
729 // NOTE: new scenario, race condition, if file starts download then chrome renames, we get -1 and think it is not downloaded
730 fileStarted = true;
731
732 // KLUDGE: file showed up but is zero bytes, this could be correct, but let's give an extra second and wait
733 // 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
734 // our file checks can run very quuckly
735 if (0 == newSize)
736 System.Threading.Thread.Sleep(1_000);
737
738 // a file being written has to be given time to grow between the two reads. taking both in the
739 // same instant made every download look finished the moment it was first seen, which is how a
740 // part-written file was taken for a complete one
741 System.Threading.Thread.Sleep(DownloadSizeSampleMs);
742
743 if (size == (newSize = WaitForFileToDownload(filePath, downloadTimeoutInSec, newSize, out downloadedFilename))) // NOTE: ensure the filesize stays the same (prolly overkill)
744 break;
745 else
746 size = newSize;
747 }
748 else if (-1 == newSize) // file isn't there..
749 break;
750 } while (true); // until we break when the file szie no longer changes
751
752 if (false == filePath.EndsWith(".crdownload") && false == filePath.EndsWith(".part"))
753 {
754 if (0 == newSize)
755 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"File [{downloadedFilename ?? filePath}] downloaded as zero bytes", null, GPALObjectType.None);
756 else if (-1 == newSize && false == fileStarted)
757 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"File [{downloadedFilename ?? filePath}] NOT downloaded", null, GPALObjectType.None);
758 // 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
759 }
760
761 return downloadedFilename;
762 }
763 internal static long WaitForFileToDownload(string filePath, int downloadTimeoutInSec, long size, out string downloadedFilename)
764 {
765 TimeSpan timeout = TimeSpan.FromSeconds(downloadTimeoutInSec); // Adjust timeout as needed
766 DateTime startTime = DateTime.Now;
767 bool fileExists = Directory.GetFiles(Path.GetDirectoryName(filePath), Path.GetFileName(filePath)).Length > 0;
768 int lastPrinted = -1; // so we print at ~30, 20, 10, 0
769
770 // a scratch file is not something to wait for. the caller only ever asks about one it has already
771 // seen, so it not being there is the browser having renamed it, which is the download finishing.
772 // the rename never says what it renamed to, so the download is whatever arrived in that directory
773 // just now, and sizing that up is what the rest of this does. waiting out the timeout for a name
774 // that has already changed is how a download that worked was reported as one that never came
775 if (false == fileExists && true == IsTemporaryDownload(filePath))
776 {
777 string arrived = NewestDownloadSince(Path.GetDirectoryName(filePath), watchArmed);
778
779 if (true == string.IsNullOrEmpty(arrived))
780 {
781 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{filePath}] has gone and nothing arrived with it, so the download did not finish.", null, GPALObjectType.None);
782 downloadedFilename = filePath;
783
784 return -1;
785 }
786
787 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{Path.GetFileName(filePath)}] became [{Path.GetFileName(arrived)}], which is what the browser called it.", null, GPALObjectType.None);
788 filePath = arrived;
789 fileExists = true;
790 }
791
792 while (false == fileExists && (DateTime.Now - startTime) < timeout)
793 {
794 TimeSpan remaining = timeout - (DateTime.Now - startTime);
795 int remainingSec = (int)Math.Ceiling(remaining.TotalSeconds); // or (int)remaining.TotalSeconds
796
797 // Print only when crossing a 10-second boundary (30>29, 20>19, etc.)
798 if (remainingSec % 10 == 0 && remainingSec != lastPrinted)
799 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Waiting up tp [{remainingSec}] sec for [{filePath}].", null, GPALObjectType.None);
800 // Sleep for a short interval before checking again
801 System.Threading.Thread.Sleep(1000); // Check every second
802 fileExists = Directory.GetFiles(Path.GetDirectoryName(filePath), Path.GetFileName(filePath)).Length > 0;
803
804 // the name we were handed is one the browser may have already moved on from. it renames the file
805 // as the download finishes and the name it picks is its own, so "Unconfirmed 175338" was never
806 // going to turn up. a file that arrived while we sat here is that download under its new name,
807 // so take it and stop waiting rather than run the timeout out on a name that has changed
808 if (false == fileExists)
809 {
810 string arrived = NewestDownloadSince(Path.GetDirectoryName(filePath), watchArmed);
811
812 if (false == string.IsNullOrEmpty(arrived) && false == arrived.Equals(filePath, StringComparison.OrdinalIgnoreCase))
813 {
814 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);
815 filePath = arrived;
816 fileExists = true;
817 }
818 }
819 }
820
821 if (false == fileExists)
822 {
823 if (false == filePath.EndsWith(".crdownload") && false == filePath.EndsWith(".part"))
824 {
825 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"File [{filePath}] never showed up.", null, GPALObjectType.None);
826 downloadedFilename = null;
827 }
828 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
829 downloadedFilename = filePath;
830
831 return -1;
832 }
833
834 // we got here in case a wildcard is passed in and we have multiple matching files
835
836 // use case: we expect a file dialog but the link downloads directly, so we have to figure it out post download
837 // as it will not download to the correct directory, so find where it downloads to and copy to the user specified directory.
838 // NOTE: CAVEAT: this depends upon the user supplied file extension which must match the expected download
839 var cutoff = DateTime.Now.AddMinutes(-2); // NOTE: hardcoded value
840
841 string newest = null;
842 DateTime newestTime = DateTime.MinValue;
843 IEnumerable<string> filesFound = Directory.EnumerateFiles(
844 Path.GetDirectoryName(filePath),
845 Path.GetFileName(filePath));
846
847 // there is more than one file that matches our filePath mask, so a wildcard must be in play
848 if (1 < filesFound.Count())
849 foreach (var f in filesFound)
850 {
851 // we may have just run the auto-update, do not mistake one of these files for our download
852 if (true == f.Equals(GPAL.GPALSettings.ChromeDriverZipFilename) ||
853 true == f.Equals(GPAL.GPALSettings.EdgeDriverFilename) ||
854 true == f.Equals(GPAL.GPALSettings.FirefoxDriverFilename))
855 continue;
856
857 var t = File.GetLastWriteTime(f);
858
859 if (t >= cutoff && t > newestTime)
860 {
861 newestTime = t;
862 newest = f;
863 }
864 }
865 else // only one file, get it
866 newest = filesFound.FirstOrDefault();
867
868 // it was there at the check above and it is gone now, which is the browser renaming it the moment it
869 // finished. that is the download completing, so say what the not-found path says and let the caller go
870 // looking for the name it landed under. First() on an empty match threw out of here instead, and the
871 // handler that caught it turned off the watcher, so nothing was ever going to report the file
872 if (null == newest)
873 {
874 downloadedFilename = true == IsTemporaryDownload(filePath) ? filePath : null;
875
876 return -1;
877 }
878
879 if (true == newest.Equals(GPAL.GPALSettings.ChromeDriverZipFilename) ||
880 true == newest.Equals(GPAL.GPALSettings.EdgeDriverFilename) ||
881 true == newest.Equals(GPAL.GPALSettings.FirefoxDriverFilename))
882 newest = null;
883
884 // grab the newest file that showed up in the last 2 minutes. we should be waiting for just one file.
885 if (null != newest)
886 {
887 downloadedFilename = newest;
888
889 // Once the file exists, attempt to open it
890 try
891 {
892 using (FileStream fs = File.Open(downloadedFilename, FileMode.Open))
893 {
894 // If opening the file succeeds, the download is complete
895 if (size != fs.Length)
896 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"File [{downloadedFilename}] downloaded with file size [{fs.Length}].", null, GPALObjectType.None);
897 return fs.Length;
898 }
899 }
900 catch
901 {
902 try
903 {
904 // the file would not open, so it is held by whoever is writing it or by a reader. that is
905 // not an answer either way, so report the size it is at and let the caller's next sample
906 // decide: a file still being written grows, a finished one does not
907 FileInfo fileInfo = new FileInfo(downloadedFilename);
908
909 if (fileInfo.Exists)
910 {
911 // If opening the file succeeds, the download is complete
912 if (size != fileInfo.Length)
913 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"File [{downloadedFilename}] locked, using FileInfo size [{fileInfo.Length}].", null, GPALObjectType.None);
914
915 return fileInfo.Length;
916 }
917 }
918 catch (Exception ex)
919 {
920 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"File [{downloadedFilename}] unable to determine file size.", null, GPALObjectType.None, ex);
921 return -1;
922 }
923 }
924 }
925 else
926 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"File [{filePath}] not found.", null, GPALObjectType.None);
927
928 downloadedFilename = null;
929
930 return -1;
931 }
932
940 public static string GetDefaultDownloadDirectory(IBrowser browser)
941 {
942 if (BrowserType.Chrome == browser.BrowserType)
943 return GetChromeDefaultDownloadDirectory(((Browser.Browser)browser).BrowserSettings.ProfileDataDirectory);
944 else if (BrowserType.Edge == browser.BrowserType)
945 return GetEdgeDefaultDownloadDirectory(((Browser.Browser)browser).BrowserSettings.ProfileDataDirectory);
946 else if (BrowserType.FireFox == browser.BrowserType)
947 return GetFirefoxDefaultDownloadDirectory(((Browser.Browser)browser).BrowserSettings.ProfileDataDirectory);
948
949 // Get the default download directory used by windows
950 string defaultDownloadDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + @"\Downloads\";
951 return defaultDownloadDirectory;
952 }
953
959 public static string GetChromeDefaultDownloadDirectory(string profilePath = null)
960 {
961 // Default profile path (change "Default" to "Profile 1" etc. if using a different one)
962 if (string.IsNullOrEmpty(profilePath))
963 {
964 string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
965 profilePath = Path.Combine(localAppData, @"Google\Chrome\User Data\Default");
966 }
967
968 string prefsPath = null;
969
970 if (false == new DirectoryInfo(profilePath).Name.ToLower().Equals("default"))
971 prefsPath = Path.Combine(profilePath, @"default\Preferences");
972 else
973 prefsPath = Path.Combine(profilePath, @"Preferences");
974
975 if (File.Exists(prefsPath))
976 {
977 string json = File.ReadAllText(prefsPath);
978 JObject prefs = JObject.Parse(json);
979
980 // Navigate to download.default_directory
981 var downloadDir = prefs["download"]?["default_directory"]?.ToString();
982
983 if (!string.IsNullOrEmpty(downloadDir))
984 return downloadDir;
985
986 // If not set explicitly, Chrome uses the system default Downloads folder
987 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
988 }
989
990 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); ;
991 }
992
999 public static string GetEdgeDefaultDownloadDirectory(string profilePath = null)
1000 {
1001 // Default profile path ("Default" folder; change to "Profile 1" etc. if needed)
1002 if (string.IsNullOrEmpty(profilePath))
1003 {
1004 string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
1005 profilePath = Path.Combine(localAppData, @"Microsoft\Edge\User Data\Default");
1006 }
1007
1008 string prefsPath = Path.Combine(profilePath, @"default\Preferences");
1009
1010 if (File.Exists(prefsPath))
1011 {
1012
1013 string json = File.ReadAllText(prefsPath);
1014 JObject prefs = JObject.Parse(json);
1015
1016 // download.default_directory only. savefile.default_directory is where "save page as" last put
1017 // something, a different setting that happens to look like this one, and it is never where a
1018 // download goes. reading it at all sends the download watcher somewhere the browser does not
1019 // download to: first when it was preferred, then again as a fallback once a profile had no
1020 // download directory of its own. a profile without one uses the windows Downloads folder, which
1021 // is what the fallback below already answers
1022 var downloadDir = prefs["download"]?["default_directory"]?.ToString();
1023
1024 if (!string.IsNullOrEmpty(downloadDir))
1025 return downloadDir;
1026
1027 // Fallback: system default Downloads folder
1028 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
1029 }
1030
1031 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
1032 }
1033
1040 public static string GetFirefoxDefaultDownloadDirectory(string profilePath = null)
1041 {
1042 string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
1043 string firefoxDir = Path.Combine(appData, @"Mozilla\Firefox");
1044 string profilesIniPath = Path.Combine(firefoxDir, "profiles.ini");
1045
1046 if (!File.Exists(profilesIniPath))
1047 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Firefox profiles.ini [{profilesIniPath}] not found.");
1048
1049 // Read and manually parse profiles.ini to find the default profile
1050 string[] lines = File.ReadAllLines(profilesIniPath);
1051 string profileDir = null;
1052 bool isRelative = true;
1053
1054 for (int i = 0; i < lines.Length; i++)
1055 {
1056 string line = lines[i].Trim();
1057
1058 if (line.StartsWith("[Profile", StringComparison.OrdinalIgnoreCase))
1059 {
1060 // Reset for new section
1061 string currentPath = null;
1062 bool currentIsRelative = true;
1063 bool isDefault = false;
1064
1065 // Look ahead in this section
1066 for (int j = i + 1; j < lines.Length; j++)
1067 {
1068 string subLine = lines[j].Trim();
1069 if (subLine.StartsWith("["))
1070 break; // next section
1071
1072 if (subLine.StartsWith("Path=", StringComparison.OrdinalIgnoreCase))
1073 currentPath = subLine.Substring(5);
1074 else if (subLine.StartsWith("IsRelative=", StringComparison.OrdinalIgnoreCase))
1075 currentIsRelative = subLine.Substring(11) == "1";
1076 else if (subLine.StartsWith("Default=", StringComparison.OrdinalIgnoreCase))
1077 isDefault = subLine.Substring(8) == "1";
1078 }
1079
1080 // If this is the default profile, use it
1081 if (isDefault && currentPath != null)
1082 {
1083 profileDir = currentPath;
1084 isRelative = currentIsRelative;
1085 break;
1086 }
1087
1088 // Otherwise remember the first profile we find as fallback
1089 if (profileDir == null && currentPath != null)
1090 {
1091 profileDir = currentPath;
1092 isRelative = currentIsRelative;
1093 }
1094 }
1095 }
1096
1097 if (!string.IsNullOrEmpty(profileDir))
1098 {
1099
1100 // Resolve full path
1101 string fullProfilePath = isRelative
1102 ? Path.Combine(firefoxDir, profileDir)
1103 : profileDir;
1104
1105 // Allow override
1106 if (!string.IsNullOrEmpty(profilePath))
1107 fullProfilePath = profilePath;
1108
1109 string prefsJsPath = Path.Combine(fullProfilePath, "prefs.js");
1110
1111 if (!File.Exists(prefsJsPath))
1112 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"prefs.js [{prefsJsPath}] not found in Firefox profile.");
1113 else
1114 {
1115 string content = File.ReadAllText(prefsJsPath);
1116
1117 // Look for: user_pref("browser.download.dir", "C:\\Path\\To\\Folder");
1118 var match = Regex.Match(content, @"user_pref\‍(""browser\.download\.dir"",\s*""([^""]*)""\‍);");
1119
1120 if (match.Success)
1121 {
1122 string dir = match.Groups[1].Value;
1123 // Handle common escapes (\\ > \‍)
1124 return dir.Replace("\\\\", "\\");
1125 }
1126
1127 // Not set > fallback to user's Downloads folder
1128 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
1129 }
1130 }
1131
1132 return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
1133 }
1134
1139 internal static List<IGPALGrid<string>> TokenizeFile(UnitOfWork currentUOW, GPALFile inputFile)
1140 {
1141 currentUOW.InputFile = inputFile;
1142 int rowCount = FileHelper.GetGrid(((IGPALFileInternal)inputFile).FileSettings, out List<IGPALGrid<string>> tokens);
1143 return tokens;
1144 }
1145
1150 private static HashSet<string> CollectDictionaryKeys(object value)
1151 {
1152 var keys = new HashSet<string>();
1153 if (value is IList<object> list)
1154 {
1155 foreach (var item in list)
1156 {
1157 if (item is IDictionary dynamicDict)
1158 {
1159 foreach (dynamic entry in dynamicDict)
1160 {
1161 string key = entry.Key?.ToString();
1162 if (key != null)
1163 {
1164 if (entry.Value is IDictionary nestedDict)
1165 {
1166 foreach (string nestedKey in CollectDictionaryKeys(nestedDict))
1167 {
1168 keys.Add($"{key}.{nestedKey}");
1169 }
1170 }
1171 else
1172 {
1173 keys.Add(key);
1174 }
1175 }
1176 }
1177 }
1178 }
1179 }
1180 else if (value is IDictionary dynamicDict)
1181 {
1182 foreach (dynamic entry in dynamicDict)
1183 {
1184 string key = entry.Key?.ToString();
1185 if (key != null)
1186 {
1187 if (entry.Value is IDictionary nestedDict)
1188 {
1189 foreach (string nestedKey in CollectDictionaryKeys(nestedDict))
1190 {
1191 keys.Add($"{key}.{nestedKey}");
1192 }
1193 }
1194 else
1195 {
1196 keys.Add(key);
1197 }
1198 }
1199 }
1200 }
1201 return keys;
1202 }
1203
1204 internal static object ProcessDictionaryValue(object value, ConverterSettings converterSettings, List<List<string>> gridRows = null, HashSet<string> gridColumns = null, string parentKey = null, HashSet<object> visited = null)
1205 {
1206 if (value == null)
1207 {
1208 if (gridRows != null && gridColumns == null)
1209 {
1210 gridRows.Add(new List<string> { "" });
1211 }
1212 return null;
1213 }
1214
1215 Type valueType = value.GetType();
1216
1217 if (visited == null)
1218 visited = new HashSet<object>(ConverterHelper.ReferenceEqualityComparer.Instance);
1219
1220 // Handle nested dictionaries
1221 if (value is IDictionary dynamicDict)
1222 {
1223 // YAML anchors/aliases can produce genuine circular object graphs (e.g. a
1224 // dictionary that contains itself via a nested list). Without this check
1225 // such cycles cause unbounded recursion and a StackOverflowException.
1226 if (!visited.Add(value))
1227 return $"Recursion to [{parentKey ?? valueType.Name}]";
1228
1229 try
1230 {
1231 var resultDict = new Dictionary<dynamic, dynamic>();
1232 var row = new List<string>();
1233
1234 if (gridColumns != null && gridRows != null)
1235 {
1236 row = new List<string>(new string[gridColumns.Count]);
1237 int colIdx = 0;
1238 foreach (string col in gridColumns)
1239 {
1240 string cellValue = "";
1241 if (col.Contains("."))
1242 {
1243 var keyParts = col.Split('.');
1244 object current = dynamicDict;
1245 bool found = true;
1246 foreach (var part in keyParts)
1247 {
1248 if (current is IDictionary currentDict && currentDict.Contains(part))
1249 {
1250 current = currentDict[part];
1251 }
1252 else
1253 {
1254 found = false;
1255 break;
1256 }
1257 }
1258 if (found)
1259 {
1260 var processedValue = ProcessDictionaryValue(current, converterSettings, null, null, null, visited);
1261 cellValue = FlattenValueForGrid(processedValue);
1262 }
1263 }
1264 else if (dynamicDict.Contains(col))
1265 {
1266 var processedValue = ProcessDictionaryValue(dynamicDict[col], converterSettings, null, null, null, visited);
1267 cellValue = FlattenValueForGrid(processedValue);
1268 }
1269 row[colIdx++] = cellValue;
1270 }
1271 gridRows.Add(row);
1272 }
1273
1274 foreach (dynamic entry in dynamicDict)
1275 {
1276 string key = entry.Key?.ToString();
1277 if (key != null)
1278 {
1279 bool isTabularList = entry.Value is IList<object> list2 && list2.Count() > 0 && list2[0] is IDictionary;
1280 HashSet<string> nestedColumns = isTabularList ? CollectDictionaryKeys(entry.Value) : null;
1281 var processedValue = ProcessDictionaryValue(entry.Value, converterSettings, isTabularList ? gridRows : null, nestedColumns, key, visited);
1282 resultDict[key] = processedValue;
1283 if (!isTabularList && gridRows != null && gridColumns == null)
1284 {
1285 row.Add(FlattenValueForGrid(processedValue));
1286 }
1287 }
1288 }
1289
1290 if (gridRows != null && row.Count > 0 && gridColumns == null)
1291 {
1292 gridRows.Add(row);
1293 }
1294 return resultDict;
1295 }
1296 finally
1297 {
1298 visited.Remove(value);
1299 }
1300 }
1301
1302 // Handle arrays/lists
1303 if (value is IList<object> list && !(value is string))
1304 {
1305 if (!visited.Add(value))
1306 return $"Recursion to [{parentKey ?? valueType.Name}]";
1307
1308 try
1309 {
1310 var resultList = new List<object>();
1311 string itemKey = string.Empty;
1312
1313 if (null != parentKey)
1314 itemKey = parentKey;
1315 else
1316 itemKey = "item";
1317
1318 foreach (var item in list)
1319 {
1320 var processedItem = ProcessDictionaryValue(item, converterSettings, gridRows, gridColumns, itemKey, visited);
1321 resultList.Add(processedItem);
1322 }
1323 return resultList;
1324 }
1325 finally
1326 {
1327 visited.Remove(value);
1328 }
1329 }
1330
1331 // Handle simple types
1332 if (ConverterHelper.IsSimpleType(valueType))
1333 {
1334 string stringValue;
1335 if (valueType == typeof(Guid) && Guid.TryParse(value.ToString(), out var guid))
1336 {
1337 stringValue = guid.ToString();
1338 if (gridRows != null && gridColumns == null)
1339 {
1340 gridRows.Add(new List<string> { stringValue });
1341 }
1342 return guid;
1343 }
1344 if (valueType == typeof(IPAddress) && IPAddress.TryParse(value.ToString(), out var ipAddress))
1345 {
1346 stringValue = ipAddress.ToString();
1347 if (gridRows != null && gridColumns == null)
1348 {
1349 gridRows.Add(new List<string> { stringValue });
1350 }
1351 return ipAddress;
1352 }
1353 if (valueType == typeof(TimeSpan) && TimeSpan.TryParse(value.ToString(), out var timeSpan))
1354 {
1355 stringValue = timeSpan.ToString();
1356 if (gridRows != null && gridColumns == null)
1357 {
1358 gridRows.Add(new List<string> { stringValue });
1359 }
1360 return timeSpan;
1361 }
1362 if (valueType == typeof(Uri))
1363 {
1364 try
1365 {
1366 var uri = new Uri(value.ToString());
1367 stringValue = uri.ToString();
1368 if (gridRows != null && gridColumns == null)
1369 {
1370 gridRows.Add(new List<string> { stringValue });
1371 }
1372 return uri;
1373 }
1374 catch
1375 {
1376 stringValue = value.ToString();
1377 if (gridRows != null && gridColumns == null)
1378 {
1379 gridRows.Add(new List<string> { stringValue });
1380 }
1381 return stringValue;
1382 }
1383 }
1384 if (valueType == typeof(System.Numerics.BigInteger))
1385 {
1386 stringValue = value.ToString();
1387 if (gridRows != null && gridColumns == null)
1388 {
1389 gridRows.Add(new List<string> { stringValue });
1390 }
1391 return stringValue;
1392 }
1393 if (valueType == typeof(System.Version))
1394 {
1395 stringValue = value.ToString();
1396 if (gridRows != null && gridColumns == null)
1397 {
1398 gridRows.Add(new List<string> { stringValue });
1399 }
1400 return stringValue;
1401 }
1402 stringValue = value.ToString();
1403 if (gridRows != null && gridColumns == null)
1404 {
1405 gridRows.Add(new List<string> { stringValue });
1406 }
1407 return value;
1408 }
1409
1410 // Handle complex objects
1411 if (ConverterHelper.IsClassType(valueType) && !valueType.IsArray)
1412 {
1413 var resultDict = ConverterHelper.ConvertClassToDictionary(value);
1414 var row = new List<string>();
1415 foreach (dynamic entry in resultDict)
1416 {
1417 row.Add(FlattenValueForGrid(entry.Value));
1418 }
1419 if (gridRows != null && row.Count > 0 && gridColumns == null)
1420 {
1421 gridRows.Add(row);
1422 }
1423 return resultDict;
1424 }
1425
1426 // Fallback for unknown types
1427 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Unknown value type [{valueType}] detected. Converting to string.", value, GPALObjectType.Other);
1428 string fallbackValue = value.ToString();
1429 if (gridRows != null && gridColumns == null)
1430 {
1431 gridRows.Add(new List<string> { fallbackValue });
1432 }
1433 return fallbackValue;
1434 }
1435
1436 private static string FlattenValueForGrid(object value)
1437 {
1438 if (value == null)
1439 {
1440 return "";
1441 }
1442
1443 if (value is IDictionary dynamicDict)
1444 {
1445 var values = new List<string>();
1446 foreach (dynamic entry in dynamicDict)
1447 {
1448 values.Add(FlattenValueForGrid(entry.Value));
1449 }
1450 return string.Join(",", values);
1451 }
1452
1453 if (value is IEnumerable<object> enumerable && !(value is string))
1454 {
1455 var values = new List<string>();
1456 foreach (var item in enumerable)
1457 {
1458 values.Add(FlattenValueForGrid(item));
1459 }
1460 return string.Join(",", values);
1461 }
1462
1463 return value.ToString();
1464 }
1465
1466 internal static bool TokenizeFile(GPALFile inputFile, ConverterSettings myConverterSettings = null)
1467 {
1468 var xmlDoc = new XmlDocument();
1469 int fileIdx = 0;
1470 StreamReader input = null;
1471 IGPALGrid<string> outGrid = null;
1472 List<IGPALGrid<string>> tokens = new List<IGPALGrid<string>>(); ;
1473 bool retval = false;
1474
1475 //if (((IGPALFileInternal)inputFile).AlreadyTokenized)
1476 //{
1477 // return true;
1478 //}
1479
1480 // Preserve a caller-supplied FirstLineIsColumnHeaders (e.g. via the converter-level
1481 // WithFirstLineHasColumnNames) as a fallback when the GPALFile itself doesn't specify
1482 // FirstLineIsColumnNames for this file index.
1483 bool callerFirstLineIsColumnHeaders = myConverterSettings?.InputFirstLineIsColumnHeaders ?? false;
1484
1485 // The output format is already known by the time TokenizeFile runs, so for delimited
1486 // input we can avoid holding a fully-populated dictionary AND a re-derived delimited
1487 // string AND a grid all in memory at once - only one of those representations is
1488 // actually consumed downstream depending on the output format.
1489 DataFormat outDataFormat = myConverterSettings?.OutDataFormat ?? DataFormat.NOTSET;
1490 bool outputNeedsDictionary = outDataFormat == DataFormat.JSON
1491 || outDataFormat == DataFormat.XML
1492 || outDataFormat == DataFormat.YAML
1493 || outDataFormat == DataFormat.CLASS
1494 || outDataFormat == DataFormat.DICTIONARY
1495 || outDataFormat == DataFormat.DATABASE;
1496
1497 ((IGPALFileInternal)inputFile).FileDictionary.Clear();
1498 foreach (string filename in inputFile.Filenames)
1499 {
1500 DataFormat inDataFormat = ConverterHelper.GetDataFormatFromExtension(filename);
1501 bool inputIsDelimited = ConverterHelper.IsDelimitedDataFormat(inDataFormat);
1502
1503 myConverterSettings = new ConverterSettings
1504 {
1505 InDelimiter = ((IGPALFileInternal)inputFile).Delimiter.Count() > fileIdx && ((IGPALFileInternal)inputFile).Delimiter[fileIdx].HasValue
1506 ? ((IGPALFileInternal)inputFile).Delimiter[fileIdx].Value
1507 : (char?)null,
1508 FieldsEnclosedInQuotes = ((IGPALFileInternal)inputFile).FieldsEnclosedInQuotes.Count() > fileIdx
1509 ? ((IGPALFileInternal)inputFile).FieldsEnclosedInQuotes[fileIdx]
1510 : false,
1511 FirstLineIsColumnHeaders = ((IGPALFileInternal)inputFile).FirstLineIsColumnNames.Count() > fileIdx
1512 ? ((IGPALFileInternal)inputFile).FirstLineIsColumnNames[fileIdx]
1513 : callerFirstLineIsColumnHeaders,
1514 IgnoreFirstLineColumnHeaders = ((IGPALFileInternal)inputFile).IgnoreFirstLineColumnNames.Count() > fileIdx
1515 ? ((IGPALFileInternal)inputFile).IgnoreFirstLineColumnNames[fileIdx]
1516 : false,
1517 ColumnNames = inputIsDelimited ? ((IGPALFileInternal)inputFile).FileSettings.ColumnList : new GPALGrid<string>(),
1518 ExcelSheetNames = ((IGPALFileInternal)inputFile).FileSettings.ExcelSheetNames,
1519 ExcelRowsPerSheet = ((IGPALFileInternal)inputFile).FileSettings.ExcelRowsPerSheet,
1520 InputFile = inputFile,
1521 };
1522 if (fileIdx < ((IGPALFileInternal)inputFile).FileSettings.Delimiter.Count())
1523 {
1524 ((IGPALFileInternal)inputFile).FileSettings.Delimiter[fileIdx] = ConverterHelper.GetDelimiterFromFormat(inDataFormat);
1525 }
1526 else
1527 {
1528 ((IGPALFileInternal)inputFile).FileSettings.Delimiter.Add(ConverterHelper.GetDelimiterFromFormat(inDataFormat));
1529 }
1530
1531 try
1532 {
1533 string directory = Path.GetDirectoryName(filename);
1534 string filenamePart = Path.GetFileName(filename);
1535 foreach (string filename2 in Directory.GetFiles(string.IsNullOrEmpty(directory) ? "." : directory, filenamePart))
1536 {
1537 using (input = new StreamReader(filename2))
1538 {
1539 input.BaseStream.Position = 0;
1540
1541 switch (inDataFormat)
1542 {
1543 case DataFormat.CARET:
1544 case DataFormat.CSV:
1545 case DataFormat.PIPE:
1546 case DataFormat.PRN:
1547 case DataFormat.TAB:
1548 var dictionary = ConverterHelper.ConvertDelimitedToDictionary(myConverterSettings, input, filename2);
1549 ((IGPALFileInternal)inputFile).FileDictionary.Add(dictionary);
1550 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1551
1552 if (outputNeedsDictionary)
1553 {
1554 // Output consumes the dictionary directly - skip re-deriving the
1555 // delimited string/grid representations entirely.
1556 ((IGPALFileInternal)inputFile).FileData.Add(string.Empty);
1557 ((IGPALFileInternal)inputFile).FileGrid.Add(GPAL.Grid.ToGPALObject());
1558 }
1559 else
1560 {
1561 dynamic delimitedData = ConverterHelper.ConvertInputDictionaryToDelimitedAndGrid(myConverterSettings, out outGrid);
1562 ((IGPALFileInternal)inputFile).FileData.Add(delimitedData);
1563 ((IGPALFileInternal)inputFile).FileGrid.Add(outGrid);
1564
1565 // The data/grid were derived from the dictionary and the output
1566 // doesn't need the dictionary itself - free its contents now.
1567 dictionary.Clear();
1568 }
1569 break;
1570
1571 case DataFormat.JSON:
1572 string fileString = input.ReadToEnd();
1573 input.BaseStream.Position = 0;
1574
1575 dynamic jsonObject = JsonConvert.DeserializeObject<dynamic>(fileString, new JsonConverter[] { new CustomJsonConverter() });
1576
1577 ((IGPALFileInternal)inputFile).FileData.Add(fileString);
1578
1579 var records = new Dictionary<object, dynamic>();
1580 var gridRows = new List<List<string>>();
1581
1582 // Helper to recursively convert any array (IEnumerable<dynamic>) to surrogate-keyed dict
1583 dynamic ConvertArrayToSurrogateDict(IEnumerable<dynamic> array, int startIndex = 0)
1584 {
1585 var surrogateDict = new Dictionary<object, dynamic>();
1586 int idx = startIndex;
1587 foreach (dynamic item in array)
1588 {
1589 surrogateDict[$"GPALKEY{idx:D4}"] = item;
1590 idx++;
1591 }
1592 return surrogateDict;
1593 }
1594
1595 // Recursively walk the structure and replace all arrays with surrogate dicts
1596 dynamic ReplaceArrays(dynamic value)
1597 {
1598 if (value is IEnumerable<dynamic> enumerable && !(value is string))
1599 {
1600 var items = Enumerable.ToList<dynamic>(enumerable);
1601
1602 // A dictionary with a complex (non-string) key serializes as an array of
1603 // {"Key": ..., "Value": ...} objects instead of a plain JSON object (see
1604 // NormalizeForCleanJson) - a complex key can't be a JSON object key at all,
1605 // and this is the standard convention (matching KeyValuePair<K,V>'s own
1606 // property names) most serializers use for this case, rather than an
1607 // invented GPAL-specific marker leaking into the file. Detect that shape
1608 // and rebuild a real dictionary here (the key may itself be a nested dict,
1609 // same as YAML's explicit-key mapping - CreateObjectFromDictionary already
1610 // reconstructs that correctly) instead of wrapping it as a plain
1611 // GPALKEY-indexed surrogate. Note: a genuine two-property {Key, Value}
1612 // data object (e.g. a real KeyValuePair-shaped list element) would also
1613 // match this shape - an acceptable, low-probability ambiguity shared by
1614 // every serializer that uses this same convention.
1615 bool looksLikePairsArray = items.Count > 0 && items.All(it =>
1616 it is IDictionary d && d.Count == 2
1617 && d.Contains("Key") && d.Contains("Value"));
1618 if (looksLikePairsArray)
1619 {
1620 var pairsDict = new Dictionary<object, dynamic>();
1621 foreach (IDictionary pairObj in items)
1622 {
1623 dynamic pairKey = ReplaceArrays(pairObj["Key"]);
1624 dynamic pairValue = ReplaceArrays(pairObj["Value"]);
1625 pairsDict[pairKey] = pairValue;
1626 }
1627 return pairsDict;
1628 }
1629
1630 // It's an array/list
1631 return ConvertArrayToSurrogateDict(items);
1632 }
1633 else if (value is IDictionary<string, dynamic> dict)
1634 {
1635 // It's an object - recurse into values
1636 var newDict = new Dictionary<object, dynamic>();
1637 foreach (var kvp in dict)
1638 {
1639 newDict[kvp.Key] = ReplaceArrays(kvp.Value);
1640 }
1641 return newDict;
1642 }
1643 else
1644 {
1645 // Primitive - return as-is
1646 return value;
1647 }
1648 }
1649
1650 dynamic processedRoot = ReplaceArrays(jsonObject);
1651
1652 // If top-level is array, wrap as surrogate dict for FileDictionary
1653 if (processedRoot is IDictionary && ConverterHelper.IsArrayLike((IDictionary)processedRoot))
1654 {
1655 records = (Dictionary<object, dynamic>)processedRoot;
1656 }
1657 else
1658 {
1659 // Single object or other - wrap under a key or just store
1660 records["root"] = processedRoot;
1661 }
1662
1663 // Grid building remains the same: iterate top-level records (now surrogate-keyed)
1664 foreach (KeyValuePair<object, dynamic> pair in records.OrderBy(p => p.Key.ToString()))
1665 {
1666 if (pair.Value is IDictionary rowDict)
1667 {
1668 var row = new List<string>();
1669 foreach (dynamic kvp in rowDict)
1670 {
1671 if (kvp.Key != null)
1672 {
1673 string key = kvp.Key.ToString();
1674 if (key.StartsWith("GPALKEY")) continue; // skip surrogates in grid
1675 var processedValue = ProcessDictionaryValue(kvp.Value, myConverterSettings, null, null, key);
1676 if (!(kvp.Value is IDictionary) && !(kvp.Value is IList<object>))
1677 {
1678 row.Add(FlattenValueForGrid(processedValue));
1679 }
1680 }
1681 }
1682 if (row.Count > 0)
1683 {
1684 gridRows.Add(row);
1685 }
1686 }
1687 }
1688
1689 ((IGPALFileInternal)inputFile).FileDictionary.Add(records);
1690
1691 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"JSON FileDictionary count: [{records.Count}]", null, GPALObjectType.Other);
1692
1693 var gridRow = GPAL.Grid.ToGPALObject();
1694 foreach (var row in gridRows)
1695 {
1696 gridRow.AddRow(row);
1697 }
1698
1699 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1700 ((IGPALFileInternal)inputFile).FileGrid.Add(gridRow);
1701 break;
1702
1703 case DataFormat.PDF:
1704 break;
1705
1706 case DataFormat.XLSX:
1707 IGPALGrid<string> grid = ExcelHelper.TokenizeExcelFile(filename2);
1708 List<string> colNames = new List<string>();
1709 List<Dictionary<object, dynamic>> returnList = new List<Dictionary<object, dynamic>>();
1710
1711 ((IGPALFileInternal)inputFile).FileGrid.Add((IGPALGrid<string>)grid.Clone());
1712
1713 // 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
1714 using (var workbook = new XLWorkbook(filename))
1715 {
1716 List<int> rowsPerSheet = new List<int>();
1717 List<string> sheetName = new List<string>();
1718
1719 foreach (IXLWorksheet worksheet in workbook.Worksheets)
1720 {
1721 int lastRowNumber = worksheet.LastRowUsed()?.RowNumber() ?? 0;
1722 // If there are blank rows after the last used row, use the sheet's defined range
1723 int totalRows = Math.Max(lastRowNumber, worksheet.RangeUsed()?.LastRow().RowNumber() ?? 0);
1724 // Or even simpler – just take the maximum row index that exists:
1725 totalRows = worksheet.Rows().LastOrDefault()?.RowNumber() ?? 0;
1726
1727 rowsPerSheet.Add(totalRows);
1728 sheetName.Add(worksheet.Name);
1729 }
1730
1731 ((IGPALFileInternal)inputFile).FileSettings.ExcelRowsPerSheet.AddRow(rowsPerSheet);
1732 ((IGPALFileInternal)inputFile).FileSettings.ExcelSheetNames.AddRow(sheetName);
1733
1734 int columnHeaders = 0;
1735 foreach (int rowCnt in rowsPerSheet)
1736 {
1737 if (myConverterSettings.FirstLineIsColumnHeaders)
1738 {
1739 colNames = grid[columnHeaders];
1740 }
1741 else
1742 {
1743 int colIdx = 0;
1744 foreach (string columnName in grid[columnHeaders].Select(v => (string)v))
1745 {
1746 colNames.Add($"Column{colIdx++}");
1747 }
1748 }
1749
1750 myConverterSettings.ColumnNames.AddRow(colNames);
1751
1752 if (0 == ((IGPALFileInternal)inputFile).FileSettings.ColumnList.Count())
1753 ((IGPALFileInternal)inputFile).FileSettings.ColumnList.AddRow(colNames);
1754
1755 int rowIdx = 0;
1756
1757 foreach (Dictionary<object, dynamic> row in ConverterHelper.ConvertGridToDictionary(colNames, grid, columnHeaders, rowCnt))
1758 {
1759 if (true == myConverterSettings.FirstLineIsColumnHeaders && 0 == rowIdx++)
1760 continue;
1761
1762 returnList.Add(row);
1763 }
1764
1765 columnHeaders += rowCnt;
1766 }
1767
1768 // now delete the column headers if denoted in settings
1769 columnHeaders = 0;
1770 int iteration = 0;
1771 foreach (int rowCnt in rowsPerSheet)
1772 {
1773 if (myConverterSettings.FirstLineIsColumnHeaders)
1774 {
1775 grid.DeleteRow(0);
1776 }
1777 columnHeaders = rowCnt - iteration++; // we have one less row than we counted in the grid for each iteration
1778 }
1779 }
1780
1781 ((IGPALFileInternal)inputFile).FileDictionary = returnList;
1782 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1783 break;
1784
1785 case DataFormat.XML:
1786 xmlDoc.PreserveWhitespace = true;
1787 xmlDoc.Load(input);
1788 input.BaseStream.Position = 0;
1789 ((IGPALFileInternal)inputFile).FileDictionary.Add(ConverterHelper.ConvertXMLToDictionary(xmlDoc));
1790 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1791 ((IGPALFileInternal)inputFile).FileData.Add(ConverterHelper.ConvertInputDictionaryToDelimitedAndGrid(myConverterSettings, out outGrid));
1792 ((IGPALFileInternal)inputFile).FileGrid.Add(outGrid);
1793 break;
1794
1795 case DataFormat.YAML:
1796 var deserializer = new DeserializerBuilder()
1797 .WithNamingConvention(CamelCaseNamingConvention.Instance)
1798 .Build();
1799
1800 string yamlText = input.ReadToEnd();
1801 input.BaseStream.Position = 0;
1802 dynamic yamlObject = deserializer.Deserialize<object>(yamlText);
1803 ((IGPALFileInternal)inputFile).FileData.Add(yamlText);
1804
1805 ((IGPALFileInternal)inputFile).FileDictionary.Clear();
1806 ((IGPALFileInternal)inputFile).FileGrid.Clear();
1807
1808 var yamlGridRows = new List<List<string>>();
1809 var processedYamlDict = new Dictionary<dynamic, dynamic>();
1810
1811 if (yamlObject is IDictionary yamlMapping)
1812 {
1813 foreach (var key in yamlMapping.Keys)
1814 {
1815 dynamic processedKey = (key is IDictionary || key is IList)
1816 ? ProcessDictionaryValue(key, myConverterSettings, yamlGridRows)
1817 : (key?.ToString() ?? "null_key");
1818 processedYamlDict[processedKey] = ProcessDictionaryValue(yamlMapping[key], myConverterSettings, yamlGridRows);
1819 }
1820 }
1821 else if (yamlObject is IList yamlSequence)
1822 {
1823 // Root is a YAML sequence, not a mapping - synthesize row keys
1824 // (same GPALKEYN convention used for headerless CSV rows).
1825 int rootIdx = 0;
1826 foreach (var item in yamlSequence)
1827 {
1828 processedYamlDict[$"GPALKEY{rootIdx++:D4}"] = ProcessDictionaryValue(item, myConverterSettings, yamlGridRows);
1829 }
1830 }
1831 else
1832 {
1833 // Root is a bare YAML scalar.
1834 processedYamlDict["GPALKEY0000"] = ProcessDictionaryValue(yamlObject, myConverterSettings, yamlGridRows);
1835 }
1836
1837 var yamlGridRow = GPAL.Grid.ToGPALObject();
1838 foreach (var row in yamlGridRows)
1839 {
1840 yamlGridRow.AddRow(row);
1841 }
1842
1843 ((IGPALFileInternal)inputFile).FileDictionary.Add(processedYamlDict);
1844 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1845 ((IGPALFileInternal)inputFile).FileGrid.Add(yamlGridRow);
1846 break;
1847
1848 case DataFormat.HTML:
1849 ((IGPALFileInternal)inputFile).FileDictionary = ConverterHelper.ConvertHtmlToDictionary(filename2);
1850 myConverterSettings.InputDictionary = ((IGPALFileInternal)inputFile).FileDictionary;
1851 dynamic inData = ConverterHelper.ConvertInputDictionaryToDelimitedAndGrid(myConverterSettings, out outGrid);
1852 ((IGPALFileInternal)inputFile).FileData.Add(inData);
1853 ((IGPALFileInternal)inputFile).FileGrid.Add(outGrid);
1854 break;
1855 }
1856
1857 retval = true;
1858 }
1859 }
1860 }
1861 catch (Exception ex)
1862 {
1863 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Problem tokenizing file [{filename}]. Continuing.", inputFile, GPALObjectType.GPALFile, ex);
1864 }
1865 fileIdx++;
1866 }
1867
1868 if (0 == ((IGPALFileInternal)inputFile).FileSettings.FileGrid.Count)
1869 FileHelper.GetGrid(((IGPALFileInternal)inputFile).FileSettings, out tokens);
1870 else
1871 tokens = ((IGPALFileInternal)inputFile).FileSettings.FileGrid;
1872
1873 ((IGPALFileInternal)inputFile).TokenList = tokens;
1874 ((IGPALFileInternal)inputFile).AlreadyTokenized = retval;
1875
1876 return retval;
1877 }
1878 [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
1879 private static extern int GetShortPathName(string lpszLongPath, StringBuilder lpszShortPath, int cchBuffer);
1880
1888 public static string GetShortPath(string longPath)
1889 {
1890 var sb = new StringBuilder(1024);
1891 int result = GetShortPathName(longPath, sb, sb.Capacity);
1892 return result > 0 ? sb.ToString() : longPath;
1893 }
1894
1901 internal static bool IsWebAddress(string fileName)
1902 {
1903 return true == Uri.TryCreate(fileName, UriKind.Absolute, out Uri uri)
1904 && (Uri.UriSchemeHttp == uri.Scheme || Uri.UriSchemeHttps == uri.Scheme);
1905 }
1906
1907
1919 internal static bool FetchOrDownloadInTab(string url, string localPath, IBrowser browser)
1920 {
1921 bool gotIt = FetchToFile(url, localPath, browser);
1922
1923 if (false == gotIt)
1924 gotIt = DownloadInTab(url, localPath, browser);
1925
1926 return gotIt;
1927 }
1957 internal static bool HasStartedDownloading(string downloadDirectory, string localPath)
1958 {
1959 return true == File.Exists(localPath)
1960 || (true == Directory.Exists(downloadDirectory)
1961 && true == Directory.EnumerateFiles(downloadDirectory).Any(file => file.EndsWith(".crdownload") || file.EndsWith(".part")));
1962 }
1963
1983 internal static int AllowanceFor(string downloadDirectory, Browser.BrowserSettings browserSettings)
1984 {
1985 int retVal = true == IsFinalizing(downloadDirectory, watchArmed)
1986 ? browserSettings.DownloadTimeoutInSec * FinalizeBudgetMultiplier
1987 : browserSettings.DownloadTimeoutInSec;
1988
1989 return retVal;
1990 }
1991
2003 internal static void DiscardAbandonedDownload(Browser.BrowserSettings browserSettings)
2004 {
2005 foreach (string scratch in watchedScratchFiles)
2006 try
2007 {
2008 if (true == File.Exists(scratch))
2009 {
2010 File.Delete(scratch);
2011 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);
2012 }
2013 }
2014 catch (Exception ex)
2015 {
2016 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not remove [{scratch}]", browserSettings.Browser, GPALObjectType.Browser, ex);
2017 }
2018
2019 watchedScratchFiles.Clear();
2020 }
2021
2022 internal const int FinalizeBudgetMultiplier = 6; // the browser's own finish-up is slower than a transfer and is not ours to hurry
2023
2035 internal static bool IsFinalizing(string downloadDirectory, DateTime since)
2036 {
2037 bool retVal = false;
2038
2039 try
2040 {
2041 if (true == Directory.Exists(downloadDirectory))
2042 // the later of the two, for the reason NewestDownloadSince takes it: windows hands a file
2043 // recreated under a name it just used the original's creation time, and a run downloading the
2044 // same file over and over reuses these names constantly. by creation time alone the browser's
2045 // brand new scratch file reads as belonging to the download before it, and the finalize
2046 // allowance is refused to the one download that is actually finalizing
2047 retVal = Directory.EnumerateFiles(downloadDirectory)
2048 .Any(file => true == IsTemporaryDownload(file)
2049 && (File.GetCreationTime(file) >= since || File.GetLastWriteTime(file) >= since));
2050 }
2051 catch (Exception ex)
2052 {
2053 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not look for a download being finished in [{downloadDirectory}]", null, GPALObjectType.None, ex);
2054 }
2055
2056 return retVal;
2057 }
2058
2059 internal static long DownloadProgress(string downloadDirectory, string localPath)
2060 {
2061 long retVal = -1;
2062
2063 try
2064 {
2065 if (true == File.Exists(localPath))
2066 retVal = new FileInfo(localPath).Length;
2067
2068 if (true == Directory.Exists(downloadDirectory))
2069 foreach (string scratch in Directory.EnumerateFiles(downloadDirectory).Where(f => true == IsTemporaryDownload(f)))
2070 retVal = (0 > retVal ? 0 : retVal) + new FileInfo(scratch).Length;
2071 }
2072 catch (Exception ex)
2073 {
2074 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Could not measure the download in [{downloadDirectory}]", null, GPALObjectType.None, ex);
2075 }
2076
2077 return retVal;
2078 }
2079 internal static bool DownloadInTab(string url, string localPath, IBrowser browser)
2080 {
2081 Browser.BrowserSettings browserSettings = ((Browser.Browser)browser).BrowserSettings;
2082
2083 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloading [{url}] with the browser to [{localPath}].", browser, GPALObjectType.Browser);
2084
2085 // the browser decides which directory a download goes to, not us, so the file lands in the download
2086 // directory first and is moved to localPath afterwards. fetch has the bytes and writes straight to
2087 // localPath, which is why the two paths differ here
2088 string downloadDirectory = GetDefaultDownloadDirectory(browser);
2089 string landsAt = Path.Combine(downloadDirectory, Path.GetFileName(localPath));
2090
2091 // ask for the filename we want. each engine takes it differently: the extension suggests a name
2092 // relative to the download directory, puppeteer takes the directory, selenium takes the full path
2093 if (true == browserSettings.UseOttoMagic)
2094 browserSettings.MagicHelper.WithDownloadFile(Path.GetFileName(localPath));
2095 else if (true == browserSettings.UsePuppeteer)
2096 browserSettings.PuppeteerClient.DownloadTo(downloadDirectory).Execute();
2097 else
2098 BrowserHelper.SetHeadlessDownload(browserSettings, landsAt);
2099
2100 // SetupDownloadWatcher is what leftclickanddownload uses. it watches the directory, waits for the
2101 // .crdownload or .part to stop growing, moves the finished file to landsAt and sets FileDownloaded
2102 browserSettings.FileDownloaded = null;
2103 SetupDownloadWatcher(landsAt, browserSettings);
2104
2105 browser.NewTab(url);
2106
2107 Stopwatch waitingToStart = Stopwatch.StartNew();
2108
2109 // FileDownloaded stays null until the watcher sets it true or false.
2110 // DownloadTimeoutInSec is how long to wait for the download to start, not to finish, so stop the
2111 // clock once a file shows up in the download directory and let the watcher take as long as it needs
2112 long lastSeen = long.MinValue;
2113
2114 while (null == browserSettings.FileDownloaded || false == browserSettings.FileDownloaded)
2115 {
2116 long progress = DownloadProgress(downloadDirectory, landsAt);
2117
2118 if (progress != lastSeen) // it moved, so it is alive and gets the whole budget again
2119 {
2120 lastSeen = progress;
2121 waitingToStart.Restart();
2122 }
2123 else if (waitingToStart.Elapsed.TotalSeconds > AllowanceFor(downloadDirectory, browserSettings))
2124 break; // nothing has happened for the whole allowance: it never started, or it died
2125
2126 Thread.Sleep(100);
2127 }
2128
2129 // we opened the tab so we close it. chromium usually closes it itself, firefox does not
2130 browser.CloseTab();
2131
2132 bool downloaded = true == browserSettings.FileDownloaded;
2133
2134 // move it out of the download directory into the working directory, where WithFileName said the file
2135 // would be. everything downstream, CopyTo and the converter included, is looking at localPath
2136 if (true == downloaded && false == landsAt.Equals(localPath, StringComparison.OrdinalIgnoreCase))
2137 {
2138 if (true == File.Exists(localPath))
2139 File.Delete(localPath);
2140
2141 File.Move(landsAt, localPath);
2142 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{landsAt}] moved to [{localPath}]", browser, GPALObjectType.Browser);
2143 }
2144
2145 if (false == downloaded)
2146 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{url}] did not download to [{downloadDirectory}] within [{browserSettings.DownloadTimeoutInSec}] seconds.", browser, GPALObjectType.Browser);
2147
2148 return downloaded;
2149 }
2163 internal static bool FetchToFile(string url, string localPath, IBrowser browser)
2164 {
2165 bool downloaded = false;
2166 string[] headers = true == File.Exists(localPath)
2167 ? new string[] { "If-Modified-Since", File.GetLastWriteTimeUtc(localPath).ToString("R") }
2168 : null;
2169
2170 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloading [{url}] with the browser to [{localPath}].", browser, GPALObjectType.Browser);
2171
2172 byte[] bytes = BrowserHelper.FetchBytes((Browser.Browser)browser, url, headers, out string lastModified);
2173
2174 if (304 == browser.ServerResponseCode)
2175 {
2176 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{url}] has not changed, using [{localPath}].", browser, GPALObjectType.Browser);
2177 downloaded = true;
2178 }
2179 else if (null == bytes)
2180 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{url}] returned no content to save as [{localPath}], HTTP [{browser.ServerResponseCode}].", browser, GPALObjectType.Browser);
2181 else if (200 > browser.ServerResponseCode || 300 <= browser.ServerResponseCode)
2182 // the body is whatever the site sends with a refusal, an error page or a json message, and saving
2183 // that as the file would leave something that looks downloaded but is not the file
2184 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{url}] returned HTTP [{browser.ServerResponseCode}], nothing saved to [{localPath}].", browser, GPALObjectType.Browser);
2185 else if (0 == bytes.Length)
2186 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"[{url}] returned an empty file, nothing saved to [{localPath}].", browser, GPALObjectType.Browser);
2187 else
2188 {
2189 File.WriteAllBytes(localPath, bytes);
2190
2191 // stamp it with the server's own modified time, so next run's question is about the content
2192 // rather than about when we happened to fetch it
2193 if (true == DateTime.TryParse(lastModified, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal, out DateTime modified))
2194 File.SetLastWriteTimeUtc(localPath, modified);
2195
2196 downloaded = true;
2197 }
2198
2199 return downloaded;
2200 }
2201
2208 public static string EnsureDirectoryEndsWithBackslash(string directoryPath)
2209 {
2210 // Check if the directory path already ends with '\'
2211 if (!directoryPath.EndsWith("\\"))
2212 {
2213 // If not, add '\'
2214 directoryPath += "\\";
2215 }
2216 return directoryPath;
2217 }
2218
2219 internal static bool TokenizeHierachicalCsvFile(GPALFile inputFile, ConverterSettings myConverterSettings)
2220 {
2221 bool retVal = true;
2222 int fileIdx = 0;
2223 // Initialize output dictionary
2224 List<Dictionary<object, dynamic>> output = new List<Dictionary<object, dynamic>>();
2225
2226 ((IGPALFileInternal)inputFile).FileDictionary.Clear();
2227
2228 foreach (string filename in inputFile.Filenames)
2229 {
2230 try
2231 {
2232 myConverterSettings = new ConverterSettings()
2233 {
2234 InDelimiter = (0 < ((IGPALFileInternal)inputFile).Delimiter.Count() && ((IGPALFileInternal)inputFile).Delimiter[fileIdx].HasValue) ? ((IGPALFileInternal)inputFile).Delimiter[fileIdx].Value : (char?)null,
2235 FieldsEnclosedInQuotes = 0 < ((IGPALFileInternal)inputFile).FieldsEnclosedInQuotes.Count() && ((IGPALFileInternal)inputFile).FieldsEnclosedInQuotes[fileIdx],
2236 FirstLineIsColumnHeaders = 0 < ((IGPALFileInternal)inputFile).FirstLineIsColumnNames.Count() && ((IGPALFileInternal)inputFile).FirstLineIsColumnNames[fileIdx],
2237 };
2238
2239 DataFormat InDataFormat = ConverterHelper.GetDataFormatFromExtension(filename);
2240
2241 string[] csvLines = File.ReadAllLines(filename);
2242
2243 // Split headers and data into lists
2244 string[] headers = csvLines[0].Trim().Split(',');
2245 List<dynamic[]> dataLines = new List<dynamic[]>();
2246
2247 // Convert data lines to arrays
2248 for (int i = 1; i < csvLines.Length; i++)
2249 {
2250 string[] data = csvLines[i].Trim().Split(',');
2251 dataLines.Add(data);
2252 }
2253
2254 // Iterate over data lines to construct output
2255 foreach (var data in dataLines)
2256 {
2257 Dictionary<object, dynamic> rowData = new Dictionary<object, dynamic>();
2258
2259 for (int i = 0; i < headers.Length; i++)
2260 {
2261 string[] headerParts = headers[i].Split('.');
2262 Dictionary<object, dynamic> currentDict = rowData;
2263
2264 for (int j = 0; j < headerParts.Length; j++)
2265 {
2266 string part = headerParts[j];
2267 bool isLastPart = (j == headerParts.Length - 1);
2268
2269 if (part.EndsWith("]"))
2270 {
2271 int indexStart = part.LastIndexOf('[');
2272 string arrayName = part.Substring(0, indexStart);
2273 int arrayIndex = int.Parse(part.Substring(indexStart + 1, part.Length - indexStart - 2));
2274
2275 // Ensure array exists
2276 if (!currentDict.ContainsKey(arrayName))
2277 {
2278 currentDict[arrayName] = new List<Dictionary<string, object>>();
2279 }
2280
2281 // Ensure array index exists
2282 List<Dictionary<object, dynamic>> currentArray = (List<Dictionary<object, dynamic>>)currentDict[arrayName];
2283 while (currentArray.Count <= arrayIndex)
2284 {
2285 currentArray.Add(new Dictionary<object, dynamic>());
2286 }
2287
2288 if (isLastPart)
2289 {
2290 currentArray[arrayIndex] = data[i];
2291 }
2292 else
2293 {
2294 currentDict = currentArray[arrayIndex];
2295 }
2296 }
2297 else
2298 {
2299 if (!currentDict.ContainsKey(part))
2300 {
2301 currentDict[part] = isLastPart ? (object)data[i] : new Dictionary<object, dynamic>();
2302 }
2303
2304 if (isLastPart)
2305 {
2306 currentDict[part] = data[i];
2307 }
2308 else
2309 {
2310 currentDict = (Dictionary<object, dynamic>)currentDict[part];
2311 }
2312 }
2313 }
2314 }
2315
2316 // Add the constructed dictionary to the output list
2317 output.Add(rowData);
2318 }
2319 }
2320 catch (Exception ex)
2321 {
2322 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to open file [{filename}]. Continuing.", inputFile, GPALObjectType.GPALFile, ex);
2323 retVal = false;
2324 }
2325 fileIdx++;
2326 }
2327 ((IGPALFileInternal)inputFile).FileDictionary = output;
2328 return retVal;
2329 }
2330 // Helper method to convert nested json Dictionary<object, object> to Dictionary<object, dynamic>
2331 internal static Dictionary<object, dynamic> ConvertToStringDynamicDict(dynamic inputDict)
2332 {
2333 var resultDict = new Dictionary<object, dynamic>();
2334 foreach (var kvp in inputDict)
2335 {
2336 if (kvp.Key != null) // Handle null keys
2337 {
2338 string key = kvp.Key.ToString();
2339 object value = kvp.Value;
2340 // Check if the value is another nested dictionary
2341 if (value is IDictionary nestedDict)
2342 {
2343 // Recursively convert the nested dictionary
2344 resultDict.Add(key, ConvertToStringDynamicDict(nestedDict));
2345 }
2346 else
2347 {
2348 // Add non-dictionary value directly
2349 resultDict.Add(key, value);
2350 }
2351 }
2352 }
2353 return resultDict;
2354 }
2355 }
2356}
2357
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 SaveToDelimited(UnitOfWork currentUOW, string filename, char delim, bool appendToFile, bool DeleteFileBeforeDownload)
Writes the unit of work's retrieved data to a delimited file, one column per selector....
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:536
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:521
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:2406
GPAL Selector used to locate Application and Browser elements. Instantiated with GPAL....
Definition Selector.cs:56
string Name
The name you gave this selector, or one assigned by GPAL [selector1, selector2...] Used in Informati...
Definition Selector.cs:858
Everything revolves around the Unit of Work. A Unit of Work is defined as one or more selectors betw...
Definition UnitOfWork.cs:39
List< string > HeaderList
Output header list, Defined using .WithHeader or, if not supplied, from the names given to selectors....
List< Selector > WithSelectorList
List of With selectors Use .WithSelector to add selectors to this list.
Definition UnitOfWork.cs:45