- Fix SQL queries in ClearExpiredCache() and GetCacheStats() functions - Resolves "no such column: cache_expires" database error - Column name now matches schema definition in migrations.go 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
362 lines
No EOL
8.1 KiB
Go
362 lines
No EOL
8.1 KiB
Go
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type CallsignManager struct {
|
|
db *sql.DB
|
|
mutex sync.RWMutex
|
|
|
|
// Compiled regex patterns for callsign parsing
|
|
airlinePattern *regexp.Regexp
|
|
flightPattern *regexp.Regexp
|
|
}
|
|
|
|
type CallsignParseResult struct {
|
|
OriginalCallsign string
|
|
AirlineCode string
|
|
FlightNumber string
|
|
IsValid bool
|
|
ParsedTime time.Time
|
|
}
|
|
|
|
func NewCallsignManager(db *sql.DB) *CallsignManager {
|
|
return &CallsignManager{
|
|
db: db,
|
|
// Match airline code (2-3 letters) followed by flight number (1-4 digits, optional letter)
|
|
airlinePattern: regexp.MustCompile(`^([A-Z]{2,3})([0-9]{1,4}[A-Z]?)$`),
|
|
// More flexible pattern for general flight identification
|
|
flightPattern: regexp.MustCompile(`^([A-Z0-9]+)([0-9]+[A-Z]?)$`),
|
|
}
|
|
}
|
|
|
|
func (cm *CallsignManager) ParseCallsign(callsign string) *CallsignParseResult {
|
|
result := &CallsignParseResult{
|
|
OriginalCallsign: callsign,
|
|
ParsedTime: time.Now(),
|
|
IsValid: false,
|
|
}
|
|
|
|
if callsign == "" {
|
|
return result
|
|
}
|
|
|
|
// Clean and normalize the callsign
|
|
normalized := strings.TrimSpace(strings.ToUpper(callsign))
|
|
|
|
// Try airline pattern first (most common for commercial flights)
|
|
if matches := cm.airlinePattern.FindStringSubmatch(normalized); len(matches) == 3 {
|
|
result.AirlineCode = matches[1]
|
|
result.FlightNumber = matches[2]
|
|
result.IsValid = true
|
|
return result
|
|
}
|
|
|
|
// Fall back to general flight pattern
|
|
if matches := cm.flightPattern.FindStringSubmatch(normalized); len(matches) == 3 {
|
|
result.AirlineCode = matches[1]
|
|
result.FlightNumber = matches[2]
|
|
result.IsValid = true
|
|
return result
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (cm *CallsignManager) GetCallsignInfo(callsign string) (*CallsignInfo, error) {
|
|
cm.mutex.RLock()
|
|
defer cm.mutex.RUnlock()
|
|
|
|
if callsign == "" {
|
|
return nil, fmt.Errorf("empty callsign")
|
|
}
|
|
|
|
// First check the cache
|
|
cached, err := cm.getCallsignFromCache(callsign)
|
|
if err == nil && cached != nil {
|
|
return cached, nil
|
|
}
|
|
|
|
// Parse the callsign
|
|
parsed := cm.ParseCallsign(callsign)
|
|
if !parsed.IsValid {
|
|
return &CallsignInfo{
|
|
OriginalCallsign: callsign,
|
|
IsValid: false,
|
|
}, nil
|
|
}
|
|
|
|
// Look up airline information
|
|
airline, err := cm.getAirlineByCode(parsed.AirlineCode)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return nil, fmt.Errorf("failed to lookup airline %s: %w", parsed.AirlineCode, err)
|
|
}
|
|
|
|
// Build the result
|
|
info := &CallsignInfo{
|
|
OriginalCallsign: callsign,
|
|
AirlineCode: parsed.AirlineCode,
|
|
FlightNumber: parsed.FlightNumber,
|
|
IsValid: true,
|
|
LastUpdated: time.Now(),
|
|
}
|
|
|
|
if airline != nil {
|
|
info.AirlineName = airline.Name
|
|
info.AirlineCountry = airline.Country
|
|
info.DisplayName = fmt.Sprintf("%s Flight %s", airline.Name, parsed.FlightNumber)
|
|
} else {
|
|
info.DisplayName = fmt.Sprintf("%s %s", parsed.AirlineCode, parsed.FlightNumber)
|
|
}
|
|
|
|
// Cache the result (fire and forget)
|
|
go func() {
|
|
if err := cm.cacheCallsignInfo(info); err != nil {
|
|
// Log error but don't fail the lookup
|
|
fmt.Printf("Warning: failed to cache callsign info for %s: %v\n", callsign, err)
|
|
}
|
|
}()
|
|
|
|
return info, nil
|
|
}
|
|
|
|
func (cm *CallsignManager) getCallsignFromCache(callsign string) (*CallsignInfo, error) {
|
|
query := `
|
|
SELECT callsign, airline_icao, flight_number, airline_name,
|
|
airline_country, '', 1, cached_at, expires_at
|
|
FROM callsign_cache
|
|
WHERE callsign = ? AND expires_at > datetime('now')
|
|
`
|
|
|
|
var info CallsignInfo
|
|
var cacheExpires time.Time
|
|
|
|
err := cm.db.QueryRow(query, callsign).Scan(
|
|
&info.OriginalCallsign,
|
|
&info.AirlineCode,
|
|
&info.FlightNumber,
|
|
&info.AirlineName,
|
|
&info.AirlineCountry,
|
|
&info.DisplayName,
|
|
&info.IsValid,
|
|
&info.LastUpdated,
|
|
&cacheExpires,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &info, nil
|
|
}
|
|
|
|
func (cm *CallsignManager) cacheCallsignInfo(info *CallsignInfo) error {
|
|
// Cache for 24 hours by default
|
|
cacheExpires := time.Now().Add(24 * time.Hour)
|
|
|
|
query := `
|
|
INSERT OR REPLACE INTO callsign_cache
|
|
(callsign, airline_icao, flight_number, airline_name,
|
|
airline_country, cached_at, expires_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
|
|
_, err := cm.db.Exec(query,
|
|
info.OriginalCallsign,
|
|
info.AirlineCode,
|
|
info.FlightNumber,
|
|
info.AirlineName,
|
|
info.AirlineCountry,
|
|
info.LastUpdated,
|
|
cacheExpires,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
func (cm *CallsignManager) getAirlineByCode(code string) (*AirlineRecord, error) {
|
|
query := `
|
|
SELECT icao_code, iata_code, name, country, active
|
|
FROM airlines
|
|
WHERE (icao_code = ? OR iata_code = ?) AND active = 1
|
|
ORDER BY
|
|
CASE WHEN icao_code = ? THEN 1 ELSE 2 END,
|
|
name
|
|
LIMIT 1
|
|
`
|
|
|
|
var airline AirlineRecord
|
|
err := cm.db.QueryRow(query, code, code, code).Scan(
|
|
&airline.ICAOCode,
|
|
&airline.IATACode,
|
|
&airline.Name,
|
|
&airline.Country,
|
|
&airline.Active,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &airline, nil
|
|
}
|
|
|
|
func (cm *CallsignManager) GetAirlinesByCountry(country string) ([]AirlineRecord, error) {
|
|
cm.mutex.RLock()
|
|
defer cm.mutex.RUnlock()
|
|
|
|
query := `
|
|
SELECT icao_code, iata_code, name, country, active
|
|
FROM airlines
|
|
WHERE country = ? AND active = 1
|
|
ORDER BY name
|
|
`
|
|
|
|
rows, err := cm.db.Query(query, country)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var airlines []AirlineRecord
|
|
for rows.Next() {
|
|
var airline AirlineRecord
|
|
err := rows.Scan(
|
|
&airline.ICAOCode,
|
|
&airline.IATACode,
|
|
&airline.Name,
|
|
&airline.Country,
|
|
&airline.Active,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
airlines = append(airlines, airline)
|
|
}
|
|
|
|
return airlines, rows.Err()
|
|
}
|
|
|
|
func (cm *CallsignManager) SearchAirlines(query string) ([]AirlineRecord, error) {
|
|
cm.mutex.RLock()
|
|
defer cm.mutex.RUnlock()
|
|
|
|
searchQuery := `
|
|
SELECT icao_code, iata_code, name, country, active
|
|
FROM airlines
|
|
WHERE (
|
|
name LIKE ? OR
|
|
icao_code LIKE ? OR
|
|
iata_code LIKE ? OR
|
|
country LIKE ?
|
|
) AND active = 1
|
|
ORDER BY
|
|
CASE
|
|
WHEN name LIKE ? THEN 1
|
|
WHEN icao_code = ? OR iata_code = ? THEN 2
|
|
ELSE 3
|
|
END,
|
|
name
|
|
LIMIT 50
|
|
`
|
|
|
|
searchTerm := "%" + strings.ToUpper(query) + "%"
|
|
exactTerm := strings.ToUpper(query)
|
|
|
|
rows, err := cm.db.Query(searchQuery,
|
|
searchTerm, searchTerm, searchTerm, searchTerm,
|
|
exactTerm, exactTerm, exactTerm,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var airlines []AirlineRecord
|
|
for rows.Next() {
|
|
var airline AirlineRecord
|
|
err := rows.Scan(
|
|
&airline.ICAOCode,
|
|
&airline.IATACode,
|
|
&airline.Name,
|
|
&airline.Country,
|
|
&airline.Active,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
airlines = append(airlines, airline)
|
|
}
|
|
|
|
return airlines, rows.Err()
|
|
}
|
|
|
|
func (cm *CallsignManager) ClearExpiredCache() error {
|
|
cm.mutex.Lock()
|
|
defer cm.mutex.Unlock()
|
|
|
|
query := `DELETE FROM callsign_cache WHERE expires_at <= datetime('now')`
|
|
_, err := cm.db.Exec(query)
|
|
return err
|
|
}
|
|
|
|
func (cm *CallsignManager) GetCacheStats() (map[string]interface{}, error) {
|
|
cm.mutex.RLock()
|
|
defer cm.mutex.RUnlock()
|
|
|
|
stats := make(map[string]interface{})
|
|
|
|
// Total cached entries
|
|
var totalCached int
|
|
err := cm.db.QueryRow(`SELECT COUNT(*) FROM callsign_cache`).Scan(&totalCached)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats["total_cached"] = totalCached
|
|
|
|
// Valid (non-expired) entries
|
|
var validCached int
|
|
err = cm.db.QueryRow(`SELECT COUNT(*) FROM callsign_cache WHERE expires_at > datetime('now')`).Scan(&validCached)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats["valid_cached"] = validCached
|
|
|
|
// Expired entries
|
|
stats["expired_cached"] = totalCached - validCached
|
|
|
|
// Total airlines in database
|
|
var totalAirlines int
|
|
err = cm.db.QueryRow(`SELECT COUNT(*) FROM airlines WHERE active = 1`).Scan(&totalAirlines)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats["total_airlines"] = totalAirlines
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func (cm *CallsignManager) LoadEmbeddedData() error {
|
|
// Check if airlines table has data
|
|
var count int
|
|
err := cm.db.QueryRow(`SELECT COUNT(*) FROM airlines`).Scan(&count)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if count > 0 {
|
|
// Data already loaded
|
|
return nil
|
|
}
|
|
|
|
// For now, we'll implement this as a placeholder
|
|
// In a full implementation, this would load embedded airline data
|
|
// from embedded files or resources
|
|
return nil
|
|
} |