using Disco.Plugins.ServiceTracker.Models; using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Text; namespace Disco.Plugins.ServiceTracker.Services { public class GoogleSheetService { private readonly GoogleSheetConfig _config; private readonly string _cachePath; public GoogleSheetService(GoogleSheetConfig config, string dataDirectory) { _config = config; _cachePath = Path.Combine(dataDirectory, "sheet_cache.csv"); } public GoogleSheetResult FetchTickets() { var result = new GoogleSheetResult(); if (!_config.Enabled || string.IsNullOrEmpty(_config.SpreadsheetId)) { result.Error = "Google Sheet integration is disabled."; return result; } string csvData = null; if (File.Exists(_cachePath)) { var cacheAge = DateTime.Now - File.GetLastWriteTime(_cachePath); if (cacheAge.TotalMinutes < _config.RefreshMinutes) csvData = File.ReadAllText(_cachePath); } if (csvData == null) { try { string url; var id = _config.SpreadsheetId.Trim(); if (id.StartsWith("2PACX", StringComparison.OrdinalIgnoreCase)) url = "https://docs.google.com/spreadsheets/d/e/" + id + "/pub?output=csv&gid=" + _config.GId; else url = "https://docs.google.com/spreadsheets/d/" + id + "/export?format=csv&gid=" + _config.GId; using (var client = new WebClient()) { client.Encoding = Encoding.UTF8; client.Headers.Add("User-Agent", "Mozilla/5.0 DiscoServiceTracker/1.0"); csvData = client.DownloadString(url); } if (csvData != null && csvData.TrimStart().StartsWith(" ParseCsv(string csvData) { var tickets = new List(); var lines = csvData.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); var usedIds = new HashSet(); for (int i = _config.HeaderRows; i < lines.Length; i++) { var fields = ParseCsvLine(lines[i]); if (fields.Count == 0) continue; bool allEmpty = true; for (int j = 0; j < fields.Count; j++) if (!string.IsNullOrWhiteSpace(fields[j])) { allEmpty = false; break; } if (allEmpty) continue; var rawTimestamp = RawGet(fields, _config.ColTimestamp); var rawEmail = RawGet(fields, _config.ColEmail); var rawTask = RawGet(fields, _config.ColTask); var rawIssue = RawGet(fields, _config.ColIssue); var ticket = new ExternalTicket { Source = "ntt", Timestamp = SafeGetDate(fields, _config.ColTimestamp), RequesterEmail = SafeGet(fields, _config.ColEmail), RequesterName = SafeGet(fields, _config.ColRequestedBy), TaskTitle = SafeGet(fields, _config.ColTask), DeviceName = SafeGet(fields, _config.ColTask), Location = SafeGet(fields, _config.ColLocation), IssueDescription = SafeGet(fields, _config.ColIssue) ?? SafeGet(fields, _config.ColTask), RawPriority = SafeGet(fields, _config.ColPriority), RawStatus = SafeGet(fields, _config.ColStatus), AssignedTo = SafeGet(fields, _config.ColAssignedTo), PreferredDate = SafeGetDateNullable(fields, _config.ColPreferredDate), SheetNotes = SafeGet(fields, _config.ColNotes) }; var stableHash = GenerateStableHash(rawTimestamp, rawEmail, rawTask, rawIssue); ticket.InternalId = StableIdFromHash(stableHash, usedIds); ticket.ExternalId = "NTT" + ticket.InternalId.ToString(); usedIds.Add(ticket.InternalId); // Use Contains for flexible status matching var status = (ticket.RawStatus ?? "").ToLower().Trim(); ticket.IsOpen = !status.Contains("complete") && !status.Contains("closed") && !status.Contains("resolved") && !status.Contains("done") && !status.Contains("cancel") && !status.Contains("finished"); if (ticket.IsOpen && (!string.IsNullOrWhiteSpace(ticket.TaskTitle) || !string.IsNullOrWhiteSpace(ticket.IssueDescription))) tickets.Add(ticket); } return tickets; } private int GenerateStableHash(string rawTimestamp, string rawEmail, string rawTask, string rawIssue) { var key = (rawTimestamp ?? "").Trim().ToLower() + "|" + (rawEmail ?? "").Trim().ToLower() + "|" + (rawTask ?? "").Trim().ToLower() + "|" + (rawIssue ?? "").Trim().ToLower(); uint hash = 5381; for (int i = 0; i < key.Length; i++) hash = ((hash << 5) + hash) + (uint)key[i]; return (int)(hash % 800000) + 100000; } private int StableIdFromHash(int baseId, HashSet used) { int id = baseId; while (used.Contains(id)) id++; return id; } private string RawGet(List fields, int index) { if (index < 0 || index >= fields.Count) return ""; return fields[index] ?? ""; } public static string MapPriority(string raw) { if (string.IsNullOrEmpty(raw)) return "medium"; var lower = raw.ToLower().Trim(); if (lower.Contains("urgent") || lower.Contains("same day") || lower.Contains("critical")) return "critical"; if (lower.Contains("high") || lower.Contains("1-2 day")) return "high"; if (lower.Contains("medium") || lower.Contains("3-5 day")) return "medium"; if (lower.Contains("low") || lower.Contains("1 week") || lower.Contains("week+")) return "low"; if (lower.Contains("scheduled") || lower.Contains("planned")) return "scheduled"; return "medium"; } private string SafeGet(List fields, int index) { if (index < 0 || index >= fields.Count) return null; var val = fields[index].Trim(); return string.IsNullOrEmpty(val) ? null : val; } private DateTime SafeGetDate(List fields, int index) { var val = SafeGet(fields, index); if (val == null) return DateTime.MinValue; DateTime dt; if (DateTime.TryParse(val, out dt)) return dt; return DateTime.MinValue; } private DateTime? SafeGetDateNullable(List fields, int index) { var val = SafeGet(fields, index); if (val == null) return null; DateTime dt; if (DateTime.TryParse(val, out dt)) return dt; return null; } private List ParseCsvLine(string line) { var fields = new List(); bool inQuotes = false; var current = new StringBuilder(); for (int i = 0; i < line.Length; i++) { char c = line[i]; if (c == '"') { if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; } else inQuotes = !inQuotes; } else if (c == ',' && !inQuotes) { fields.Add(current.ToString()); current.Clear(); } else if (c != '\r') current.Append(c); } fields.Add(current.ToString()); return fields; } } public class GoogleSheetResult { public List Tickets { get; set; } public string Error { get; set; } public string Warning { get; set; } public GoogleSheetResult() { Tickets = new List(); } } }