370 lines
9.6 KiB
Go
370 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
const EugeneId = 23
|
|
const NegativeInfractionId = 9
|
|
const BaseUrl = "https://sheets.office.clintonambulance.com"
|
|
const EmployeesTableId = "m12l1ptaalz9ciq"
|
|
const NoPointsViewId = "vwjtzv5npqodcy71"
|
|
const InfractionsTableId = "myr46d3wdd4hhyg"
|
|
|
|
// NocoDBRecord represents a record from the NocoDB API
|
|
type NocoDBRecord struct {
|
|
ID int `json:"ID"`
|
|
Name string `json:"Name"`
|
|
}
|
|
|
|
// NocoDBResponse represents the API response structure
|
|
type NocoDBResponse struct {
|
|
List []NocoDBRecord `json:"list"`
|
|
PageInfo struct {
|
|
TotalRows int `json:"totalRows"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"pageSize"`
|
|
IsFirstPage bool `json:"isFirstPage"`
|
|
IsLastPage bool `json:"isLastPage"`
|
|
} `json:"pageInfo"`
|
|
}
|
|
|
|
type NocoDBRequest struct {
|
|
EmployeeId int `json:"Employees_id"`
|
|
ReportedBy int `json:"Employees_id1"`
|
|
InfractionId int `json:"Infractions_id"`
|
|
Date string `json:"Date"`
|
|
}
|
|
|
|
func noPointsUrl() string {
|
|
combinedUrl, _ := url.JoinPath(BaseUrl, "api/v2/tables", EmployeesTableId, "records")
|
|
return fmt.Sprintf("%s?viewId=%s", combinedUrl, NoPointsViewId)
|
|
}
|
|
|
|
func addInfractionsUrl() string {
|
|
combinedUrl, _ := url.JoinPath(BaseUrl, "api/v2/tables", InfractionsTableId, "records")
|
|
return combinedUrl
|
|
}
|
|
|
|
func firstDayOfMonth() string {
|
|
// Load America/Detroit timezone
|
|
loc, err := time.LoadLocation("America/Detroit")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Get current time in Detroit timezone
|
|
now := time.Now().In(loc)
|
|
|
|
// Create midnight of the first day of the current month
|
|
firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
|
|
|
// Return ISO8601 formatted string
|
|
return firstDay.Format(time.RFC3339)
|
|
}
|
|
|
|
// levenshteinDistance calculates the edit distance between two strings
|
|
func levenshteinDistance(s1, s2 string) int {
|
|
s1 = strings.ToLower(s1)
|
|
s2 = strings.ToLower(s2)
|
|
|
|
len1 := len(s1)
|
|
len2 := len(s2)
|
|
|
|
matrix := make([][]int, len1+1)
|
|
for i := range matrix {
|
|
matrix[i] = make([]int, len2+1)
|
|
matrix[i][0] = i
|
|
}
|
|
for j := range matrix[0] {
|
|
matrix[0][j] = j
|
|
}
|
|
|
|
for i := 1; i <= len1; i++ {
|
|
for j := 1; j <= len2; j++ {
|
|
cost := 0
|
|
if s1[i-1] != s2[j-1] {
|
|
cost = 1
|
|
}
|
|
matrix[i][j] = min(
|
|
matrix[i-1][j]+1,
|
|
min(matrix[i][j-1]+1, matrix[i-1][j-1]+cost),
|
|
)
|
|
}
|
|
}
|
|
return matrix[len1][len2]
|
|
}
|
|
|
|
// normalizeName removes extra whitespace and punctuation, converts to lowercase
|
|
func normalizeName(name string) string {
|
|
// Remove extra whitespace
|
|
name = strings.TrimSpace(name)
|
|
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
|
|
|
|
// Remove common punctuation but keep hyphens
|
|
name = strings.Map(func(r rune) rune {
|
|
if unicode.IsLetter(r) || unicode.IsSpace(r) || r == '-' {
|
|
return unicode.ToLower(r)
|
|
}
|
|
return -1
|
|
}, name)
|
|
|
|
return name
|
|
}
|
|
|
|
// convertNameFormat converts "LastName, FirstName" to "FirstName LastName"
|
|
func convertNameFormat(name string) string {
|
|
parts := strings.Split(name, ",")
|
|
if len(parts) == 2 {
|
|
lastName := strings.TrimSpace(parts[0])
|
|
firstName := strings.TrimSpace(parts[1])
|
|
return firstName + " " + lastName
|
|
}
|
|
return name
|
|
}
|
|
|
|
// toTitleCase converts a name to Title Case
|
|
func toTitleCase(name string) string {
|
|
words := strings.Fields(name)
|
|
for i, word := range words {
|
|
if len(word) > 0 {
|
|
// Handle hyphenated names
|
|
if strings.Contains(word, "-") {
|
|
parts := strings.Split(word, "-")
|
|
for j, part := range parts {
|
|
if len(part) > 0 {
|
|
parts[j] = strings.ToUpper(string(part[0])) + strings.ToLower(part[1:])
|
|
}
|
|
}
|
|
words[i] = strings.Join(parts, "-")
|
|
} else {
|
|
words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:])
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(words, " ")
|
|
}
|
|
|
|
// parseXLSFile extracts names from the HTML-formatted XLS file
|
|
func parseXLSFile(filename string) ([]string, error) {
|
|
content, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read XLS file: %w", err)
|
|
}
|
|
|
|
// Extract names from HTML table cells with class="smallbold"
|
|
// Pattern matches: <td valign=top class="smallbold">NAME</td>
|
|
pattern := regexp.MustCompile(`<td[^>]*class="smallbold"[^>]*>([^<]+)</td>`)
|
|
matches := pattern.FindAllStringSubmatch(string(content), -1)
|
|
|
|
names := make([]string, 0)
|
|
seen := make(map[string]bool)
|
|
|
|
for _, match := range matches {
|
|
if len(match) > 1 {
|
|
name := strings.TrimSpace(match[1])
|
|
// Filter out non-name entries (like "Regular Shift", "Total:", etc.)
|
|
if name != "" && !strings.Contains(name, "Total") &&
|
|
!strings.Contains(name, "Shift") && !strings.Contains(name, ":") &&
|
|
strings.Contains(name, ",") { // Names should have comma
|
|
normalized := normalizeName(name)
|
|
if !seen[normalized] {
|
|
seen[normalized] = true
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return names, nil
|
|
}
|
|
|
|
// fetchNamesFromAPI fetches names from the NocoDB API
|
|
func fetchFromAPI(apiURL, apiToken string) ([]NocoDBRecord, error) {
|
|
records := []NocoDBRecord{}
|
|
offset := 0
|
|
limit := 25
|
|
isLastPage := false
|
|
|
|
for !isLastPage {
|
|
// Build URL with pagination
|
|
url := fmt.Sprintf("%s&offset=%d&limit=%d", apiURL, offset, limit)
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// Add authorization header
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiToken))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Make the request
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch data from API: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check response status
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// Read and parse response
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
var nocoDBResp NocoDBResponse
|
|
if err := json.Unmarshal(body, &nocoDBResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
|
}
|
|
|
|
// Extract names from records
|
|
records = append(records, nocoDBResp.List...)
|
|
|
|
// Check if we've reached the last page
|
|
isLastPage = nocoDBResp.PageInfo.IsLastPage
|
|
|
|
// Update offset for next page
|
|
offset += limit
|
|
}
|
|
|
|
return records, nil
|
|
}
|
|
|
|
func hasMatch(record NocoDBRecord, candidates []string, threshold int) bool {
|
|
normalizedName := normalizeName(toTitleCase(record.Name))
|
|
bestDistance := threshold + 1
|
|
|
|
for _, candidate := range candidates {
|
|
normalizedCandidate := normalizeName(candidate)
|
|
distance := levenshteinDistance(normalizedName, normalizedCandidate)
|
|
|
|
if distance < bestDistance {
|
|
bestDistance = distance
|
|
}
|
|
}
|
|
|
|
if bestDistance <= threshold {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func main() {
|
|
// Define command-line flags
|
|
xlsFile := flag.String("xls", "", "Path to the XLS file (required)")
|
|
apiToken := flag.String("api-token", "", "NocoDB API token (or set NOCODB_API_TOKEN environment variable)")
|
|
threshold := flag.Int("threshold", 3, "Maximum edit distance for fuzzy matching")
|
|
|
|
flag.Parse()
|
|
|
|
// Get API token from environment if not provided via flag
|
|
token := *apiToken
|
|
if token == "" {
|
|
token = os.Getenv("NOCODB_API_TOKEN")
|
|
}
|
|
|
|
// Validate required arguments
|
|
if *xlsFile == "" {
|
|
fmt.Println("Error: -xls flag is required")
|
|
fmt.Println("\nUsage:")
|
|
flag.PrintDefaults()
|
|
os.Exit(1)
|
|
}
|
|
|
|
if token == "" {
|
|
fmt.Println("Error: API token is required")
|
|
fmt.Println("Provide it via -api-token flag or NOCODB_API_TOKEN environment variable")
|
|
fmt.Println("\nUsage:")
|
|
flag.PrintDefaults()
|
|
os.Exit(1)
|
|
}
|
|
|
|
xlsNames, err := parseXLSFile(*xlsFile)
|
|
if err != nil {
|
|
fmt.Printf("Error parsing XLS file: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
records, err := fetchFromAPI(noPointsUrl(), token)
|
|
if err != nil {
|
|
fmt.Printf("Error fetching data from API: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Convert XLS names to same format as API names (FirstName LastName) and normalize casing
|
|
xlsNamesConverted := make([]string, len(xlsNames))
|
|
for i, name := range xlsNames {
|
|
converted := convertNameFormat(name)
|
|
xlsNamesConverted[i] = toTitleCase(converted)
|
|
}
|
|
|
|
overlaps := lo.Filter(records, func(r NocoDBRecord, _ int) bool {
|
|
return hasMatch(r, xlsNamesConverted, *threshold)
|
|
})
|
|
|
|
requestObjects := lo.Map(overlaps, func(r NocoDBRecord, _ int) NocoDBRequest {
|
|
return NocoDBRequest{
|
|
EmployeeId: r.ID,
|
|
ReportedBy: EugeneId,
|
|
InfractionId: NegativeInfractionId,
|
|
Date: firstDayOfMonth(),
|
|
}
|
|
})
|
|
|
|
// Marshal request objects to JSON
|
|
jsonData, err := json.Marshal(requestObjects)
|
|
if err != nil {
|
|
fmt.Printf("Error marshaling request objects: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Create POST request
|
|
req, err := http.NewRequest("POST", addInfractionsUrl(), strings.NewReader(string(jsonData)))
|
|
if err != nil {
|
|
fmt.Printf("Error creating POST request: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Add authorization header
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Make the request
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
fmt.Printf("Error submitting request: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check response status
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
fmt.Printf("Request failed with status %d: %s\n", resp.StatusCode, string(body))
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("Successfully submitted %d records\n", len(requestObjects))
|
|
}
|