skyview/internal/database/manager_callsign.go
Ole-Morten Duesund 2bffa2c418 style: Apply code formatting with go fmt
- Run 'make format' to ensure all Go code follows standard formatting
- Maintains consistent code style across the entire codebase
- No functional changes, only whitespace and formatting improvements
2025-09-01 10:05:29 +02:00

362 lines
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
}