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: NAME pattern := regexp.MustCompile(`]*class="smallbold"[^>]*>([^<]+)`) 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)) }