diff --git a/Services/GoogleSheetService.cs b/Services/GoogleSheetService.cs index f1eeab8..49463e3 100644 --- a/Services/GoogleSheetService.cs +++ b/Services/GoogleSheetService.cs @@ -29,7 +29,6 @@ namespace Disco.Plugins.ServiceTracker.Services string csvData = null; - // Check cache if (File.Exists(_cachePath)) { var cacheAge = DateTime.Now - File.GetLastWriteTime(_cachePath); @@ -60,11 +59,11 @@ namespace Disco.Plugins.ServiceTracker.Services if (File.Exists(_cachePath)) { csvData = File.ReadAllText(_cachePath); - result.Warning = "Got HTML instead of CSV. Using cache. Publish sheet as CSV format."; + result.Warning = "Got HTML instead of CSV. Using cache."; } else { - result.Error = "Google returned HTML not CSV. Publish the sheet tab as CSV: File > Share > Publish to web > select tab > CSV > Publish."; + result.Error = "Google returned HTML not CSV. Publish as CSV."; return result; } } @@ -82,7 +81,7 @@ namespace Disco.Plugins.ServiceTracker.Services } else { - result.Error = "Fetch failed: " + ex.Message + ". Publish the sheet tab as CSV."; + result.Error = "Fetch failed: " + ex.Message; return result; } } @@ -97,7 +96,7 @@ namespace Disco.Plugins.ServiceTracker.Services { result.Tickets = ParseCsv(csvData); if (result.Tickets.Count == 0 && string.IsNullOrEmpty(result.Warning)) - result.Warning = "Sheet OK but no open tickets. All may be Completed/Closed."; + result.Warning = "Sheet OK but no open tickets found."; } catch (Exception ex) { @@ -111,7 +110,7 @@ namespace Disco.Plugins.ServiceTracker.Services { var tickets = new List(); var lines = csvData.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); - int nextId = 100001; + var usedIds = new HashSet(); for (int i = _config.HeaderRows; i < lines.Length; i++) { @@ -126,13 +125,14 @@ namespace Disco.Plugins.ServiceTracker.Services var taskTitle = SafeGet(fields, _config.ColTask); var issueDesc = SafeGet(fields, _config.ColIssue); var requestedBy = SafeGet(fields, _config.ColRequestedBy); + var email = SafeGet(fields, _config.ColEmail); + var timestamp = SafeGetDate(fields, _config.ColTimestamp); var ticket = new ExternalTicket { - InternalId = nextId++, Source = "ntt", - Timestamp = SafeGetDate(fields, _config.ColTimestamp), - RequesterEmail = SafeGet(fields, _config.ColEmail), + Timestamp = timestamp, + RequesterEmail = email, RequesterName = requestedBy, TaskTitle = taskTitle, DeviceName = taskTitle, @@ -145,9 +145,12 @@ namespace Disco.Plugins.ServiceTracker.Services SheetNotes = SafeGet(fields, _config.ColNotes) }; - ticket.ExternalId = GenerateStableId(ticket); + // Generate STABLE IDs from content - same row always gets same ID + var stableHash = GenerateStableHash(timestamp, email, taskTitle); + ticket.InternalId = StableIdFromHash(stableHash, usedIds); + ticket.ExternalId = "NTT" + ticket.InternalId.ToString(); + usedIds.Add(ticket.InternalId); - // Determine if open var status = (ticket.RawStatus ?? "").ToLower().Trim(); ticket.IsOpen = status != "completed" && status != "closed" && status != "resolved" && status != "done" && status != "cancelled"; @@ -159,41 +162,52 @@ namespace Disco.Plugins.ServiceTracker.Services return tickets; } + /// + /// Generates a deterministic hash from the row's key fields. + /// Same timestamp + email + task always produces the same number. + /// + private int GenerateStableHash(DateTime timestamp, string email, string task) + { + var key = timestamp.ToString("yyyyMMddHHmmss") + "|" + + (email ?? "").ToLower().Trim() + "|" + + (task ?? "").ToLower().Trim(); + + // Use a simple but stable hash (DJB2-like) + uint hash = 5381; + for (int i = 0; i < key.Length; i++) + hash = ((hash << 5) + hash) + (uint)key[i]; + + return (int)(hash % 800000) + 100000; // Range: 100000-899999 + } + + /// + /// Returns the hash as an ID, resolving collisions by incrementing. + /// + private int StableIdFromHash(int baseId, HashSet used) + { + int id = baseId; + while (used.Contains(id)) + id++; + return id; + } + public static string MapPriority(string raw) { if (string.IsNullOrEmpty(raw)) return "medium"; var lower = raw.ToLower().Trim(); - - // Handle emoji priority format: "🔴 Urgent (Same Day)", "🟠 High (1–2 days)" etc. - if (lower.Contains("urgent") || lower.Contains("same day") || lower.Contains("critical")) - return "critical"; - if (lower.Contains("high") || lower.Contains("1-2 day") || lower.Contains("1\u20132 day")) - return "high"; - if (lower.Contains("medium") || lower.Contains("3-5 day") || lower.Contains("3\u20135 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"; - - // Try matching by emoji colour - if (raw.Contains("\U0001F534") || raw.Contains("\uD83D\uDD34")) return "critical"; // red circle - if (raw.Contains("\U0001F7E0") || raw.Contains("\uD83D\uDFE0")) return "high"; // orange circle - if (raw.Contains("\U0001F7E1") || raw.Contains("\uD83D\uDFE1")) return "medium"; // yellow circle - if (raw.Contains("\U0001F7E2") || raw.Contains("\uD83D\uDFE2")) return "low"; // green circle - + 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"; + // Emoji fallback + if (raw.Contains("\U0001F534")) return "critical"; + if (raw.Contains("\U0001F7E0")) return "high"; + if (raw.Contains("\U0001F7E1")) return "medium"; + if (raw.Contains("\U0001F7E2")) return "low"; return "medium"; } - private string GenerateStableId(ExternalTicket t) - { - var key = (t.Timestamp.ToString("yyyyMMddHHmm") + "|" + (t.RequesterEmail ?? "") + "|" + (t.TaskTitle ?? "")).ToLower(); - int hash = 0; - for (int i = 0; i < key.Length; i++) - hash = ((hash << 5) - hash) + key[i]; - return "NTT" + Math.Abs(hash).ToString().PadLeft(6, '0').Substring(0, 6); - } - private string SafeGet(List fields, int index) { if (index < 0 || index >= fields.Count) return null; @@ -227,11 +241,7 @@ namespace Disco.Plugins.ServiceTracker.Services 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; - } + 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); }