using Aspose.Gis; using GitConverter.Lib.Logging; using GitConverter.Lib.Models; using SharpCompress.Archives; using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; namespace GitConverter.Lib.Converters { /// /// Reusable utility helpers used by converters and the orchestration service. /// /// /// Responsibilities /// - Path validation and preparation for conversion workflows (input, output, temp). /// - Lightweight archive inspection without extraction (entry listing, presence checks). /// - Directory creation and writability probing used by . /// /// Behaviour details /// - ValidateAndPreparePaths: /// - Ensures the input path exists (file or directory). /// - Ensures the output path is a directory (creates it if missing) and verifies basic writability via a probe file. /// - Ensures the temp folder path is present or creatable and verifies writability. /// - Returns a friendly on expected problems (missing path, permission issues) rather than throwing. /// - TryListArchiveEntries: /// - Uses SharpCompress (preferred) and falls back to System.IO.Compression.ZipArchive for listing entries. /// - Returns null when the archive cannot be inspected. /// - ArchiveContainsAllRequiredExtensions: /// - Detects required extensions and folder markers (for example .gdb directory markers) across archive entry paths. /// /// Safety & diagnostics /// - TryCreateAndVerifyDirectory performs a small probe (create + delete a zero-byte file) to assert writability and surfaces clear log messages on failure. /// - Helpers are conservative: they swallow unexpected exceptions where appropriate and return friendly failures to callers. /// /// Threading / side-effects /// - Helpers are stateless and suitable for concurrent use. They perform local I/O and may create directories as a side-effect. /// - Callers should prefer ephemeral per-conversion temp/output folders to avoid cross-run contention. /// public static class ConverterUtils { /// /// Validate the input / output / temp paths before conversion. /// Returns a non-null Failure on validation failure, otherwise null. /// /// /// Checks performed /// - gisInputFilePath must exist (file or directory). /// - outputFilePath will be created if missing; if it exists but is a file an error is returned. /// - tempFolderPath will be created if missing; if it exists but is a file an error is returned. /// - Attempts to create the temp directories and verifies basic writability by creating a small probe file. /// Behaviour /// - Returns a ConversionResult.Failure(...) describing the problem for expected issues (missing path, permission). /// - Logs diagnostic details (including exception messages) but does not rethrow exceptions for expected errors. /// public static ConversionResult ValidateAndPreparePaths(string gisInputFilePath, string outputFilePath, string tempFolderPath) { if (string.IsNullOrWhiteSpace(gisInputFilePath)) { Log.Error("ValidateAndPreparePaths: Input path is null or empty."); return ConversionResult.Failure("Input path is required."); } if (!File.Exists(gisInputFilePath) && !Directory.Exists(gisInputFilePath)) { Log.Error($"ValidateAndPreparePaths: Input path does not exist: '{gisInputFilePath}'."); return ConversionResult.Failure($"Input path does not exist: '{gisInputFilePath}'."); } if (string.IsNullOrWhiteSpace(outputFilePath)) { Log.Error("ValidateAndPreparePaths: Output path is null or empty."); return ConversionResult.Failure("Output path is required."); } // If an output path exists and is a file -> error if (File.Exists(outputFilePath) && !Directory.Exists(outputFilePath)) { Log.Error($"ValidateAndPreparePaths: Output path '{outputFilePath}' is a file, not a directory."); return ConversionResult.Failure($"Cannot use output path '{outputFilePath}' because is a file, not a directory."); } Log.Debug($"ValidateAndPreparePaths: ensuring output folder exists and is writable: '{outputFilePath}'"); // Ensure output folder exists and is writable (create if missing) var outPrep = TryCreateAndVerifyDirectory(outputFilePath); if (outPrep != null) { Log.Warn($"ValidateAndPreparePaths: failed to prepare output folder '{outputFilePath}': {outPrep.Message}"); return outPrep; } if (string.IsNullOrWhiteSpace(tempFolderPath)) { Log.Error("ValidateAndPreparePaths: Temporary folder path is null or empty."); return ConversionResult.Failure("Temporary folder path is required."); } if (File.Exists(tempFolderPath) && !Directory.Exists(tempFolderPath)) { Log.Error($"ValidateAndPreparePaths: Temp path '{tempFolderPath}' is a file, not a directory."); return ConversionResult.Failure($"Cannot use temp path '{tempFolderPath}' because is a file, not a directory."); } var tempPrep = TryCreateAndVerifyDirectory(tempFolderPath); if (tempPrep != null) return tempPrep; Log.Debug("ValidateAndPreparePaths: Path validation and preparation succeeded."); return null; } /// /// Heuristic: returns true when the path looks like a common archive file by extension. /// public static bool IsArchiveFile(string path) { if (string.IsNullOrWhiteSpace(path)) return false; var lower = path.Trim().ToLowerInvariant(); // Composite extensions - check first var compositeSuffixes = new[] { ".tar.gz", ".tar.bz2", ".tar.xz" }; foreach (var suf in compositeSuffixes) { if (lower.EndsWith(suf, StringComparison.OrdinalIgnoreCase)) return true; } var extensions = new[] { ".zip", ".kmz", ".tar", ".tgz", ".gz", ".bz2", ".xz", ".7z", ".rar" }; var ext = Path.GetExtension(lower); if (string.IsNullOrEmpty(ext)) return false; return extensions.Contains(ext, StringComparer.OrdinalIgnoreCase); } /// /// Read entry names from an archive without extracting. /// Returns null when the archive cannot be inspected. /// public static IEnumerable TryListArchiveEntries(string archivePath) { if (string.IsNullOrWhiteSpace(archivePath)) { Log.Warn("TryListArchiveEntries: archivePath is null or empty."); return null; } if (!File.Exists(archivePath)) { Log.Warn($"TryListArchiveEntries: archive file not found: '{archivePath}'."); return null; } // Prefer SharpCompress for broad archive support. try { using (var archive = ArchiveFactory.Open(archivePath)) { var list = new List(); foreach (var entry in archive.Entries) { try { if (entry == null) continue; if (entry.IsDirectory) continue; var name = entry.Key; if (!string.IsNullOrEmpty(name)) list.Add(name); } catch (Exception inner) { Log.Debug($"TryListArchiveEntries: skipped entry due to error: {inner.Message}"); } } return list; } } catch (Exception ex) { Log.Debug($"TryListArchiveEntries: SharpCompress failed to open '{archivePath}': {ex.Message}. Attempting Zip fallback."); try { using (var stream = File.OpenRead(archivePath)) using (var za = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false)) { return za.Entries.Select(e => e.FullName).ToList(); } } catch (Exception zipEx) { Log.Warn($"TryListArchiveEntries: failed to read archive '{archivePath}' with SharpCompress and ZipArchive: {zipEx.Message}"); return null; } } } /// /// Check whether the archive contains at least one entry with each of the provided extensions (case-insensitive). /// This method now also inspects path segments for directory markers like "Name.gdb" so zipped FileGDB folders are detected. /// public static bool ArchiveContainsAllRequiredExtensions(string archivePath, IEnumerable requiredExtensions) { if (string.IsNullOrWhiteSpace(archivePath)) return false; if (requiredExtensions == null) return false; var entries = TryListArchiveEntries(archivePath); if (entries == null) return false; // Build a normalized set of discovered "extensions" including: // - extension of the entry name (last segment) // - any suffixes found on intermediate path segments (e.g. "MyGdb.gdb") var normalized = entries .SelectMany(e => { var found = new List(); try { var ext = Path.GetExtension(e); if (!string.IsNullOrEmpty(ext)) found.Add(ext.ToLowerInvariant()); var segments = e.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); foreach (var seg in segments) { var idx = seg.LastIndexOf('.'); if (idx > 0 && idx < seg.Length - 1) { var segExt = seg.Substring(idx).ToLowerInvariant(); found.Add(segExt); } } } catch { } return found; }) .Where(x => !string.IsNullOrEmpty(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var req in requiredExtensions) { if (string.IsNullOrWhiteSpace(req)) continue; var r = req.ToLowerInvariant(); if (!normalized.Any(n => n == r)) return false; } return true; } /// /// Map a canonical conversion option name to an Aspose instance. /// Returns null when unknown. /// public static Driver ConversionOptionToDriver(string option) { if (string.IsNullOrWhiteSpace(option)) return null; switch (option.Trim().ToLowerInvariant()) { case "geojson": return Drivers.GeoJson; case "geojsonseq": return Drivers.GeoJsonSeq; case "esrijson": return Drivers.EsriJson; case "gdb": return Drivers.FileGdb; case "kml": return Drivers.Kml; case "kmz": return Drivers.Kml; case "shapefile": return Drivers.Shapefile; case "topojson": return Drivers.TopoJson; case "osm": return Drivers.OsmXml; case "gpx": return Drivers.Gpx; case "gml": return Drivers.Gml; case "mapinfointerchange": return Drivers.MapInfoInterchange; case "mapinfotab": return Drivers.MapInfoTab; case "csv": return Drivers.Csv; case "geopackage": return Drivers.GeoPackage; default: return null; } } /// /// Best-effort cleanup of the temp folder. This deletes the directory recursively. /// Swallows exceptions and logs warnings. /// public static void TryCleanupTempFolder(string tempFolderPath) { if (string.IsNullOrWhiteSpace(tempFolderPath)) return; try { if (Directory.Exists(tempFolderPath)) { Log.Debug($"Cleaning up temp folder '{tempFolderPath}'."); Directory.Delete(tempFolderPath, recursive: true); Log.Info($"Temp folder '{tempFolderPath}' deleted."); } } catch (Exception ex) { Log.Warn($"Failed to clean up temp folder '{tempFolderPath}': {ex.Message}"); } } /// /// Best-effort delete of a set of extracted/intermediate files. /// public static void CleanupExtractedFiles(IEnumerable files) { if (files == null) return; foreach (var f in files) { if (string.IsNullOrWhiteSpace(f)) continue; try { if (File.Exists(f)) { File.Delete(f); Log.Debug($"CleanupExtractedFiles: deleted '{f}'."); } } catch (Exception ex) { Log.Warn($"CleanupExtractedFiles: failed to delete '{f}': {ex.Message}"); } } } /// /// Format a UTC timestamp for human presentation. /// Includes both local time and canonical UTC value. /// public static string FormatTimestampForDisplay(DateTime utcTimestamp) { if (utcTimestamp.Kind != DateTimeKind.Utc) utcTimestamp = DateTime.SpecifyKind(utcTimestamp, DateTimeKind.Utc); var local = utcTimestamp.ToLocalTime(); return $"{local:o} (Local) | {utcTimestamp:o} (UTC)"; } // --- internal helpers ------------------------------------------------ /// /// Attempt to create the directory and verify writable by creating a short probe file. /// Returns a ConversionResult.Failure on error, or null on success. /// private static ConversionResult TryCreateAndVerifyDirectory(string folderPath) { try { if (!Directory.Exists(folderPath)) { Log.Debug($"TryCreateAndVerifyDirectory: Creating folder '{folderPath}'."); Directory.CreateDirectory(folderPath); } // Quick writability probe: create and delete a small temp file. var probeFile = Path.Combine(folderPath, Path.GetRandomFileName()); try { using (var fs = File.Create(probeFile)) { /* zero-byte */ } File.Delete(probeFile); } catch (UnauthorizedAccessException uex) { Log.Error($"TryCreateAndVerifyDirectory: access denied for '{folderPath}': {uex.Message}", uex); return ConversionResult.Failure($"Access denied for folder '{folderPath}': {uex.Message}"); } catch (IOException ioex) { Log.Error($"TryCreateAndVerifyDirectory: I/O error for '{folderPath}': {ioex.Message}", ioex); return ConversionResult.Failure($"I/O error for folder '{folderPath}': {ioex.Message}"); } catch (Exception ex) { Log.Error($"TryCreateAndVerifyDirectory: unexpected error for '{folderPath}': {ex.Message}", ex); return ConversionResult.Failure($"Unable to prepare folder '{folderPath}': {ex.Message}"); } return null; } catch (UnauthorizedAccessException uex) { Log.Error($"TryCreateAndVerifyDirectory: access denied creating '{folderPath}': {uex.Message}", uex); return ConversionResult.Failure($"Access denied creating folder '{folderPath}': {uex.Message}"); } catch (IOException ioex) { Log.Error($"TryCreateAndVerifyDirectory: I/O error creating '{folderPath}': {ioex.Message}", ioex); return ConversionResult.Failure($"I/O error creating folder '{folderPath}': {ioex.Message}"); } catch (Exception ex) { Log.Error($"TryCreateAndVerifyDirectory: failed to create '{folderPath}': {ex.Message}", ex); return ConversionResult.Failure($"Unable to create folder '{folderPath}': {ex.Message}"); } } } }