initial commit

This commit is contained in:
Eugene Howe
2026-02-12 07:55:43 -05:00
commit af09672ee3
6 changed files with 396 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
*.sqlite
*.pdf
*.log
build/*.txt
*.txt
/tmp/
inputs/

1
.tool-versions Normal file
View File

@@ -0,0 +1 @@
golang 1.25.6

369
find_overlaps.go Normal file
View File

@@ -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: <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))
}

7
go.mod Normal file
View File

@@ -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

4
go.sum Normal file
View File

@@ -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=

7
steps.md Normal file
View File

@@ -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 <saved report from above> -api-token <TOKEN>`