commit af09672ee3bb4c006449792c96715406855bfbe5 Author: Eugene Howe Date: Thu Feb 12 07:55:43 2026 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dfff1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.sqlite +*.pdf +*.log +build/*.txt +*.txt + +/tmp/ +inputs/ diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..f645bcd --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.25.6 diff --git a/find_overlaps.go b/find_overlaps.go new file mode 100644 index 0000000..d8b6eff --- /dev/null +++ b/find_overlaps.go @@ -0,0 +1,369 @@ +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)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..248b742 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module clintonambulance.com/calculate_negative_points + +go 1.25.5 + +require github.com/samber/lo v1.52.0 + +require golang.org/x/text v0.22.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f1fa120 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= diff --git a/steps.md b/steps.md new file mode 100644 index 0000000..51c9e88 --- /dev/null +++ b/steps.md @@ -0,0 +1,7 @@ +# Run "Employee Hours Worked By Date Span" Report + +- Go to `ESO Scheduler > Employees > Employee Reports > Employee Hours Worked By Date Span` +- Select the date range of now - 2 months ago. For example, in December 2025, the date range is October 1 - October 31 +- Select Excel as the output format +- Click query and save the file in this directory +- `go run find_overlaps.go -xls -api-token `