fix: stable deterministic IDs from content hash - same row always gets same Job ID across refreshes
This commit is contained in:
@@ -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<ExternalTicket>();
|
||||
var lines = csvData.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
int nextId = 100001;
|
||||
var usedIds = new HashSet<int>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic hash from the row's key fields.
|
||||
/// Same timestamp + email + task always produces the same number.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the hash as an ID, resolving collisions by incrementing.
|
||||
/// </summary>
|
||||
private int StableIdFromHash(int baseId, HashSet<int> 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<string> 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user