initial commit
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
*.sqlite
|
||||
*.pdf
|
||||
*.log
|
||||
build/*.txt
|
||||
*.txt
|
||||
|
||||
/tmp/
|
||||
inputs/
|
||||
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@@ -0,0 +1 @@
|
||||
golang 1.25.6
|
||||
369
find_overlaps.go
Normal file
369
find_overlaps.go
Normal 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
7
go.mod
Normal 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
4
go.sum
Normal 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
7
steps.md
Normal 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>`
|
||||
Reference in New Issue
Block a user