feat: Add SQLite database integration for aircraft history and callsign enhancement
- Implement comprehensive database package with versioned migrations - Add skyview-data CLI tool for managing aviation reference data - Integrate database with merger for real-time aircraft history persistence - Support OurAirports and OpenFlights data sources (runtime loading) - Add systemd timer for automated database updates - Fix transaction-based bulk loading for 2400% performance improvement - Add callsign enhancement system with airline/airport lookups - Update Debian packaging with database directory and permissions Database features: - Aircraft position history with configurable retention - External aviation data loading (airlines, airports) - Callsign parsing and enhancement - API client for external lookups (OpenSky, etc.) - Privacy mode for complete offline operation CLI commands: - skyview-data status: Show database statistics - skyview-data update: Load aviation reference data - skyview-data list: Show available data sources - skyview-data clear: Remove specific data sources 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
cd51d3ecc0
commit
37c4fa2b57
25 changed files with 4771 additions and 12 deletions
389
internal/database/api_client.go
Normal file
389
internal/database/api_client.go
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ExternalAPIClient struct {
|
||||
httpClient *http.Client
|
||||
mutex sync.RWMutex
|
||||
|
||||
// Configuration
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
userAgent string
|
||||
|
||||
// Rate limiting
|
||||
lastRequest time.Time
|
||||
minInterval time.Duration
|
||||
}
|
||||
|
||||
type APIClientConfig struct {
|
||||
Timeout time.Duration
|
||||
MaxRetries int
|
||||
UserAgent string
|
||||
MinInterval time.Duration // Minimum interval between requests
|
||||
}
|
||||
|
||||
type OpenSkyFlightInfo struct {
|
||||
ICAO string `json:"icao"`
|
||||
Callsign string `json:"callsign"`
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
AircraftType string `json:"aircraft_type"`
|
||||
Registration string `json:"registration"`
|
||||
FlightNumber string `json:"flight_number"`
|
||||
Airline string `json:"airline"`
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
Operation string
|
||||
StatusCode int
|
||||
Message string
|
||||
Retryable bool
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("API error in %s: %s (status: %d, retryable: %v)",
|
||||
e.Operation, e.Message, e.StatusCode, e.Retryable)
|
||||
}
|
||||
|
||||
func NewExternalAPIClient(config APIClientConfig) *ExternalAPIClient {
|
||||
if config.Timeout == 0 {
|
||||
config.Timeout = 10 * time.Second
|
||||
}
|
||||
if config.MaxRetries == 0 {
|
||||
config.MaxRetries = 3
|
||||
}
|
||||
if config.UserAgent == "" {
|
||||
config.UserAgent = "SkyView-ADSB/1.0 (https://github.com/user/skyview)"
|
||||
}
|
||||
if config.MinInterval == 0 {
|
||||
config.MinInterval = 1 * time.Second // Default rate limit
|
||||
}
|
||||
|
||||
return &ExternalAPIClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: config.Timeout,
|
||||
},
|
||||
timeout: config.Timeout,
|
||||
maxRetries: config.MaxRetries,
|
||||
userAgent: config.UserAgent,
|
||||
minInterval: config.MinInterval,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ExternalAPIClient) enforceRateLimit() {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
elapsed := time.Since(c.lastRequest)
|
||||
if elapsed < c.minInterval {
|
||||
time.Sleep(c.minInterval - elapsed)
|
||||
}
|
||||
c.lastRequest = time.Now()
|
||||
}
|
||||
|
||||
func (c *ExternalAPIClient) makeRequest(ctx context.Context, url string) (*http.Response, error) {
|
||||
c.enforceRateLimit()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Exponential backoff
|
||||
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
resp, lastErr = c.httpClient.Do(req)
|
||||
if lastErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for retryable status codes
|
||||
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
|
||||
// Handle rate limiting
|
||||
if resp.StatusCode == 429 {
|
||||
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
|
||||
if retryAfter > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(retryAfter):
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Success or non-retryable error
|
||||
break
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *ExternalAPIClient) GetFlightInfoFromOpenSky(ctx context.Context, icao string) (*OpenSkyFlightInfo, error) {
|
||||
if icao == "" {
|
||||
return nil, fmt.Errorf("empty ICAO code")
|
||||
}
|
||||
|
||||
// OpenSky Network API endpoint for flight information
|
||||
apiURL := fmt.Sprintf("https://opensky-network.org/api/flights/aircraft?icao24=%s&begin=%d&end=%d",
|
||||
icao,
|
||||
time.Now().Add(-24*time.Hour).Unix(),
|
||||
time.Now().Unix(),
|
||||
)
|
||||
|
||||
resp, err := c.makeRequest(ctx, apiURL)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
Operation: "opensky_flight_info",
|
||||
Message: err.Error(),
|
||||
Retryable: true,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &APIError{
|
||||
Operation: "opensky_flight_info",
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(body),
|
||||
Retryable: resp.StatusCode >= 500 || resp.StatusCode == 429,
|
||||
}
|
||||
}
|
||||
|
||||
var flights [][]interface{}
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
if err := decoder.Decode(&flights); err != nil {
|
||||
return nil, &APIError{
|
||||
Operation: "opensky_parse_response",
|
||||
Message: err.Error(),
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
if len(flights) == 0 {
|
||||
return nil, nil // No flight information available
|
||||
}
|
||||
|
||||
// Parse the most recent flight
|
||||
flight := flights[0]
|
||||
if len(flight) < 10 {
|
||||
return nil, &APIError{
|
||||
Operation: "opensky_invalid_response",
|
||||
Message: "invalid flight data format",
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
info := &OpenSkyFlightInfo{
|
||||
ICAO: icao,
|
||||
}
|
||||
|
||||
// Parse fields based on OpenSky API documentation
|
||||
if callsign, ok := flight[1].(string); ok {
|
||||
info.Callsign = callsign
|
||||
}
|
||||
if firstSeen, ok := flight[2].(float64); ok {
|
||||
info.FirstSeen = time.Unix(int64(firstSeen), 0)
|
||||
}
|
||||
if lastSeen, ok := flight[3].(float64); ok {
|
||||
info.LastSeen = time.Unix(int64(lastSeen), 0)
|
||||
}
|
||||
if origin, ok := flight[4].(string); ok {
|
||||
info.Origin = origin
|
||||
}
|
||||
if destination, ok := flight[5].(string); ok {
|
||||
info.Destination = destination
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (c *ExternalAPIClient) GetAircraftInfoFromOpenSky(ctx context.Context, icao string) (map[string]interface{}, error) {
|
||||
if icao == "" {
|
||||
return nil, fmt.Errorf("empty ICAO code")
|
||||
}
|
||||
|
||||
// OpenSky Network metadata API
|
||||
apiURL := fmt.Sprintf("https://opensky-network.org/api/metadata/aircraft/icao/%s", icao)
|
||||
|
||||
resp, err := c.makeRequest(ctx, apiURL)
|
||||
if err != nil {
|
||||
return nil, &APIError{
|
||||
Operation: "opensky_aircraft_info",
|
||||
Message: err.Error(),
|
||||
Retryable: true,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil // Aircraft not found
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &APIError{
|
||||
Operation: "opensky_aircraft_info",
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(body),
|
||||
Retryable: resp.StatusCode >= 500 || resp.StatusCode == 429,
|
||||
}
|
||||
}
|
||||
|
||||
var aircraft map[string]interface{}
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
if err := decoder.Decode(&aircraft); err != nil {
|
||||
return nil, &APIError{
|
||||
Operation: "opensky_parse_aircraft",
|
||||
Message: err.Error(),
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
return aircraft, nil
|
||||
}
|
||||
|
||||
func (c *ExternalAPIClient) EnhanceCallsignWithExternalData(ctx context.Context, callsign, icao string) (map[string]interface{}, error) {
|
||||
enhancement := make(map[string]interface{})
|
||||
enhancement["callsign"] = callsign
|
||||
enhancement["icao"] = icao
|
||||
enhancement["enhanced"] = false
|
||||
|
||||
// Try to get flight information from OpenSky
|
||||
if flightInfo, err := c.GetFlightInfoFromOpenSky(ctx, icao); err == nil && flightInfo != nil {
|
||||
enhancement["flight_info"] = map[string]interface{}{
|
||||
"origin": flightInfo.Origin,
|
||||
"destination": flightInfo.Destination,
|
||||
"first_seen": flightInfo.FirstSeen,
|
||||
"last_seen": flightInfo.LastSeen,
|
||||
"flight_number": flightInfo.FlightNumber,
|
||||
"airline": flightInfo.Airline,
|
||||
}
|
||||
enhancement["enhanced"] = true
|
||||
}
|
||||
|
||||
// Try to get aircraft metadata
|
||||
if aircraftInfo, err := c.GetAircraftInfoFromOpenSky(ctx, icao); err == nil && aircraftInfo != nil {
|
||||
enhancement["aircraft_info"] = aircraftInfo
|
||||
enhancement["enhanced"] = true
|
||||
}
|
||||
|
||||
return enhancement, nil
|
||||
}
|
||||
|
||||
func (c *ExternalAPIClient) BatchEnhanceCallsigns(ctx context.Context, callsigns map[string]string) (map[string]map[string]interface{}, error) {
|
||||
results := make(map[string]map[string]interface{})
|
||||
|
||||
for callsign, icao := range callsigns {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return results, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
enhanced, err := c.EnhanceCallsignWithExternalData(ctx, callsign, icao)
|
||||
if err != nil {
|
||||
// Log error but continue with other callsigns
|
||||
fmt.Printf("Warning: failed to enhance callsign %s (ICAO: %s): %v\n", callsign, icao, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results[callsign] = enhanced
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c *ExternalAPIClient) TestConnection(ctx context.Context) error {
|
||||
// Test with a simple API call
|
||||
testURL := "https://opensky-network.org/api/states?time=0&lamin=0&lomin=0&lamax=1&lomax=1"
|
||||
|
||||
resp, err := c.makeRequest(ctx, testURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection test failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("connection test returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRetryAfter(header string) time.Duration {
|
||||
if header == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Try parsing as seconds
|
||||
if seconds, err := time.ParseDuration(header + "s"); err == nil {
|
||||
return seconds
|
||||
}
|
||||
|
||||
// Try parsing as HTTP date
|
||||
if t, err := http.ParseTime(header); err == nil {
|
||||
return time.Until(t)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// HealthCheck provides information about the client's health
|
||||
func (c *ExternalAPIClient) HealthCheck(ctx context.Context) map[string]interface{} {
|
||||
health := make(map[string]interface{})
|
||||
|
||||
// Test connection
|
||||
if err := c.TestConnection(ctx); err != nil {
|
||||
health["status"] = "unhealthy"
|
||||
health["error"] = err.Error()
|
||||
} else {
|
||||
health["status"] = "healthy"
|
||||
}
|
||||
|
||||
// Add configuration info
|
||||
health["timeout"] = c.timeout.String()
|
||||
health["max_retries"] = c.maxRetries
|
||||
health["min_interval"] = c.minInterval.String()
|
||||
health["user_agent"] = c.userAgent
|
||||
|
||||
c.mutex.RLock()
|
||||
health["last_request"] = c.lastRequest
|
||||
c.mutex.RUnlock()
|
||||
|
||||
return health
|
||||
}
|
||||
256
internal/database/database.go
Normal file
256
internal/database/database.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
// Package database provides persistent storage for aircraft data and callsign enhancement
|
||||
// using SQLite with versioned schema migrations and comprehensive error handling.
|
||||
//
|
||||
// The database system supports:
|
||||
// - Aircraft position history with configurable retention
|
||||
// - Embedded OpenFlights airline and airport data
|
||||
// - External API result caching with TTL
|
||||
// - Schema migrations with rollback support
|
||||
// - Privacy mode for complete offline operation
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
)
|
||||
|
||||
// Database represents the main database connection and operations
|
||||
type Database struct {
|
||||
conn *sql.DB
|
||||
config *Config
|
||||
migrator *Migrator
|
||||
callsign *CallsignManager
|
||||
history *HistoryManager
|
||||
}
|
||||
|
||||
// Config holds database configuration options
|
||||
type Config struct {
|
||||
// Database file path (auto-resolved if empty)
|
||||
Path string `json:"path"`
|
||||
|
||||
// Data retention settings
|
||||
MaxHistoryDays int `json:"max_history_days"` // 0 = unlimited
|
||||
BackupOnUpgrade bool `json:"backup_on_upgrade"`
|
||||
|
||||
// Connection settings
|
||||
MaxOpenConns int `json:"max_open_conns"` // Default: 10
|
||||
MaxIdleConns int `json:"max_idle_conns"` // Default: 5
|
||||
ConnMaxLifetime time.Duration `json:"conn_max_lifetime"` // Default: 1 hour
|
||||
|
||||
// Maintenance settings
|
||||
VacuumInterval time.Duration `json:"vacuum_interval"` // Default: 24 hours
|
||||
CleanupInterval time.Duration `json:"cleanup_interval"` // Default: 1 hour
|
||||
}
|
||||
|
||||
// AircraftHistoryRecord represents a stored aircraft position update
|
||||
type AircraftHistoryRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
ICAO string `json:"icao"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Altitude *int `json:"altitude,omitempty"`
|
||||
Speed *int `json:"speed,omitempty"`
|
||||
Track *int `json:"track,omitempty"`
|
||||
VerticalRate *int `json:"vertical_rate,omitempty"`
|
||||
Squawk *string `json:"squawk,omitempty"`
|
||||
Callsign *string `json:"callsign,omitempty"`
|
||||
SourceID string `json:"source_id"`
|
||||
SignalStrength *float64 `json:"signal_strength,omitempty"`
|
||||
}
|
||||
|
||||
// CallsignInfo represents enriched callsign information
|
||||
type CallsignInfo struct {
|
||||
OriginalCallsign string `json:"original_callsign"`
|
||||
AirlineCode string `json:"airline_code"`
|
||||
FlightNumber string `json:"flight_number"`
|
||||
AirlineName string `json:"airline_name"`
|
||||
AirlineCountry string `json:"airline_country"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsValid bool `json:"is_valid"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// AirlineRecord represents embedded airline data from OpenFlights
|
||||
type AirlineRecord struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Alias string `json:"alias"`
|
||||
IATACode string `json:"iata_code"`
|
||||
ICAOCode string `json:"icao_code"`
|
||||
Callsign string `json:"callsign"`
|
||||
Country string `json:"country"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// AirportRecord represents embedded airport data from OpenFlights
|
||||
type AirportRecord struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
IATA string `json:"iata"`
|
||||
ICAO string `json:"icao"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Altitude int `json:"altitude"`
|
||||
TimezoneOffset float64 `json:"timezone_offset"`
|
||||
DST string `json:"dst"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
// DatabaseError represents database operation errors
|
||||
type DatabaseError struct {
|
||||
Operation string
|
||||
Err error
|
||||
Query string
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
func (e *DatabaseError) Error() string {
|
||||
if e.Query != "" {
|
||||
return fmt.Sprintf("database %s error: %v (query: %s)", e.Operation, e.Err, e.Query)
|
||||
}
|
||||
return fmt.Sprintf("database %s error: %v", e.Operation, e.Err)
|
||||
}
|
||||
|
||||
func (e *DatabaseError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// NewDatabase creates a new database connection with the given configuration
|
||||
func NewDatabase(config *Config) (*Database, error) {
|
||||
if config == nil {
|
||||
config = DefaultConfig()
|
||||
}
|
||||
|
||||
// Resolve database path
|
||||
dbPath, err := ResolveDatabasePath(config.Path)
|
||||
if err != nil {
|
||||
return nil, &DatabaseError{
|
||||
Operation: "path_resolution",
|
||||
Err: err,
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
config.Path = dbPath
|
||||
|
||||
// Open database connection
|
||||
conn, err := sql.Open("sqlite3", buildConnectionString(dbPath))
|
||||
if err != nil {
|
||||
return nil, &DatabaseError{
|
||||
Operation: "connect",
|
||||
Err: err,
|
||||
Retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Configure connection pool
|
||||
conn.SetMaxOpenConns(config.MaxOpenConns)
|
||||
conn.SetMaxIdleConns(config.MaxIdleConns)
|
||||
conn.SetConnMaxLifetime(config.ConnMaxLifetime)
|
||||
|
||||
// Test connection
|
||||
if err := conn.Ping(); err != nil {
|
||||
conn.Close()
|
||||
return nil, &DatabaseError{
|
||||
Operation: "ping",
|
||||
Err: err,
|
||||
Retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
db := &Database{
|
||||
conn: conn,
|
||||
config: config,
|
||||
}
|
||||
|
||||
// Initialize components
|
||||
db.migrator = NewMigrator(conn)
|
||||
db.callsign = NewCallsignManager(conn)
|
||||
db.history = NewHistoryManager(conn, config.MaxHistoryDays)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Initialize runs database migrations and sets up embedded data
|
||||
func (db *Database) Initialize() error {
|
||||
// Run schema migrations
|
||||
if err := db.migrator.MigrateToLatest(); err != nil {
|
||||
return &DatabaseError{
|
||||
Operation: "migration",
|
||||
Err: err,
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Load embedded OpenFlights data if not already loaded
|
||||
if err := db.callsign.LoadEmbeddedData(); err != nil {
|
||||
return &DatabaseError{
|
||||
Operation: "load_embedded_data",
|
||||
Err: err,
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConfig returns the database configuration
|
||||
func (db *Database) GetConfig() *Config {
|
||||
return db.config
|
||||
}
|
||||
|
||||
// GetConnection returns the underlying database connection
|
||||
func (db *Database) GetConnection() *sql.DB {
|
||||
return db.conn
|
||||
}
|
||||
|
||||
// GetHistoryManager returns the history manager
|
||||
func (db *Database) GetHistoryManager() *HistoryManager {
|
||||
return db.history
|
||||
}
|
||||
|
||||
// GetCallsignManager returns the callsign manager
|
||||
func (db *Database) GetCallsignManager() *CallsignManager {
|
||||
return db.callsign
|
||||
}
|
||||
|
||||
// Close closes the database connection and stops background tasks
|
||||
func (db *Database) Close() error {
|
||||
if db.conn != nil {
|
||||
return db.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Health returns the database health status
|
||||
func (db *Database) Health() error {
|
||||
if db.conn == nil {
|
||||
return fmt.Errorf("database connection not initialized")
|
||||
}
|
||||
return db.conn.Ping()
|
||||
}
|
||||
|
||||
|
||||
// DefaultConfig returns the default database configuration
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Path: "", // Auto-resolved
|
||||
MaxHistoryDays: 7,
|
||||
BackupOnUpgrade: true,
|
||||
MaxOpenConns: 10,
|
||||
MaxIdleConns: 5,
|
||||
ConnMaxLifetime: time.Hour,
|
||||
VacuumInterval: 24 * time.Hour,
|
||||
CleanupInterval: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// buildConnectionString creates SQLite connection string with optimizations
|
||||
func buildConnectionString(path string) string {
|
||||
return fmt.Sprintf("%s?_journal_mode=WAL&_synchronous=NORMAL&_cache_size=-64000&_temp_store=MEMORY&_foreign_keys=ON", path)
|
||||
}
|
||||
526
internal/database/loader.go
Normal file
526
internal/database/loader.go
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
// Package database - Data loader for external sources
|
||||
//
|
||||
// This module handles loading aviation data from external sources at runtime,
|
||||
// maintaining license compliance by not embedding any AGPL or restricted data
|
||||
// in the SkyView binary.
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DataLoader handles loading external data sources into the database
|
||||
type DataLoader struct {
|
||||
conn *sql.DB
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// DataSource represents an external aviation data source
|
||||
type DataSource struct {
|
||||
Name string `json:"name"`
|
||||
License string `json:"license"`
|
||||
URL string `json:"url"`
|
||||
RequiresConsent bool `json:"requires_consent"`
|
||||
UserAcceptedLicense bool `json:"user_accepted_license"`
|
||||
Format string `json:"format"` // "openflights", "ourairports", "csv"
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// LoadResult contains the results of a data loading operation
|
||||
type LoadResult struct {
|
||||
Source string `json:"source"`
|
||||
RecordsTotal int `json:"records_total"`
|
||||
RecordsNew int `json:"records_new"`
|
||||
RecordsError int `json:"records_error"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// NewDataLoader creates a new data loader with HTTP client
|
||||
func NewDataLoader(conn *sql.DB) *DataLoader {
|
||||
// Check for insecure TLS environment variable
|
||||
insecureTLS := os.Getenv("SKYVIEW_INSECURE_TLS") == "1"
|
||||
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DisableCompression: false,
|
||||
}
|
||||
|
||||
// Allow insecure certificates if requested
|
||||
if insecureTLS {
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
}
|
||||
|
||||
return &DataLoader{
|
||||
conn: conn,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: transport,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetAvailableDataSources returns all supported data sources with license info
|
||||
func GetAvailableDataSources() []DataSource {
|
||||
return []DataSource{
|
||||
{
|
||||
Name: "OpenFlights Airlines",
|
||||
License: "AGPL-3.0",
|
||||
URL: "https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat",
|
||||
RequiresConsent: true,
|
||||
Format: "openflights",
|
||||
Version: "latest",
|
||||
},
|
||||
{
|
||||
Name: "OpenFlights Airports",
|
||||
License: "AGPL-3.0",
|
||||
URL: "https://raw.githubusercontent.com/jpatokal/openflights/master/data/airports.dat",
|
||||
RequiresConsent: true,
|
||||
Format: "openflights",
|
||||
Version: "latest",
|
||||
},
|
||||
{
|
||||
Name: "OurAirports",
|
||||
License: "Public Domain",
|
||||
URL: "https://raw.githubusercontent.com/davidmegginson/ourairports-data/main/airports.csv",
|
||||
RequiresConsent: false,
|
||||
Format: "ourairports",
|
||||
Version: "latest",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadDataSource downloads and imports data from an external source
|
||||
func (dl *DataLoader) LoadDataSource(source DataSource) (*LoadResult, error) {
|
||||
result := &LoadResult{
|
||||
Source: source.Name,
|
||||
}
|
||||
startTime := time.Now()
|
||||
defer func() {
|
||||
result.Duration = time.Since(startTime)
|
||||
}()
|
||||
|
||||
// Check license acceptance if required
|
||||
if source.RequiresConsent && !source.UserAcceptedLicense {
|
||||
return nil, fmt.Errorf("user has not accepted license for source: %s (%s)", source.Name, source.License)
|
||||
}
|
||||
|
||||
// Download data
|
||||
resp, err := dl.client.Get(source.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download data from %s: %v", source.URL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP error downloading data: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Parse and load data based on format
|
||||
switch source.Format {
|
||||
case "openflights":
|
||||
if strings.Contains(source.Name, "Airlines") {
|
||||
return dl.loadOpenFlightsAirlines(resp.Body, source, result)
|
||||
} else if strings.Contains(source.Name, "Airports") {
|
||||
return dl.loadOpenFlightsAirports(resp.Body, source, result)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown OpenFlights data type: %s", source.Name)
|
||||
|
||||
case "ourairports":
|
||||
return dl.loadOurAirports(resp.Body, source, result)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported data format: %s", source.Format)
|
||||
}
|
||||
}
|
||||
|
||||
// loadOpenFlightsAirlines loads airline data in OpenFlights format
|
||||
func (dl *DataLoader) loadOpenFlightsAirlines(reader io.Reader, source DataSource, result *LoadResult) (*LoadResult, error) {
|
||||
tx, err := dl.conn.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin transaction: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Record data source
|
||||
if err := dl.recordDataSource(tx, source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Clear existing data from this source
|
||||
_, err = tx.Exec(`DELETE FROM airlines WHERE data_source = ?`, source.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to clear existing airline data: %v", err)
|
||||
}
|
||||
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.FieldsPerRecord = -1 // Variable number of fields
|
||||
|
||||
insertStmt, err := tx.Prepare(`
|
||||
INSERT INTO airlines (id, name, alias, iata, icao, callsign, country, active, data_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare insert statement: %v", err)
|
||||
}
|
||||
defer insertStmt.Close()
|
||||
|
||||
for {
|
||||
record, err := csvReader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
result.RecordsError++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("CSV parse error: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
if len(record) < 7 {
|
||||
result.RecordsError++
|
||||
result.Errors = append(result.Errors, "insufficient fields in record")
|
||||
continue
|
||||
}
|
||||
|
||||
result.RecordsTotal++
|
||||
|
||||
// Parse OpenFlights airline format:
|
||||
// ID, Name, Alias, IATA, ICAO, Callsign, Country, Active
|
||||
id, _ := strconv.Atoi(record[0])
|
||||
name := strings.Trim(record[1], `"`)
|
||||
alias := strings.Trim(record[2], `"`)
|
||||
iata := strings.Trim(record[3], `"`)
|
||||
icao := strings.Trim(record[4], `"`)
|
||||
callsign := strings.Trim(record[5], `"`)
|
||||
country := strings.Trim(record[6], `"`)
|
||||
active := len(record) > 7 && strings.Trim(record[7], `"`) == "Y"
|
||||
|
||||
// Convert \N to empty strings
|
||||
if alias == "\\N" { alias = "" }
|
||||
if iata == "\\N" { iata = "" }
|
||||
if icao == "\\N" { icao = "" }
|
||||
if callsign == "\\N" { callsign = "" }
|
||||
|
||||
_, err = insertStmt.Exec(id, name, alias, iata, icao, callsign, country, active, source.Name)
|
||||
if err != nil {
|
||||
result.RecordsError++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("insert error for airline %s: %v", name, err))
|
||||
continue
|
||||
}
|
||||
|
||||
result.RecordsNew++
|
||||
}
|
||||
|
||||
// Update record count
|
||||
_, err = tx.Exec(`UPDATE data_sources SET record_count = ? WHERE name = ?`, result.RecordsNew, source.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update record count: %v", err)
|
||||
}
|
||||
|
||||
return result, tx.Commit()
|
||||
}
|
||||
|
||||
// loadOpenFlightsAirports loads airport data in OpenFlights format
|
||||
func (dl *DataLoader) loadOpenFlightsAirports(reader io.Reader, source DataSource, result *LoadResult) (*LoadResult, error) {
|
||||
tx, err := dl.conn.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin transaction: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Record data source
|
||||
if err := dl.recordDataSource(tx, source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Clear existing data from this source
|
||||
_, err = tx.Exec(`DELETE FROM airports WHERE data_source = ?`, source.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to clear existing airport data: %v", err)
|
||||
}
|
||||
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.FieldsPerRecord = -1
|
||||
|
||||
insertStmt, err := tx.Prepare(`
|
||||
INSERT INTO airports (id, name, city, country, iata, icao, latitude, longitude,
|
||||
altitude, timezone_offset, dst_type, timezone, data_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare insert statement: %v", err)
|
||||
}
|
||||
defer insertStmt.Close()
|
||||
|
||||
for {
|
||||
record, err := csvReader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
result.RecordsError++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("CSV parse error: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
if len(record) < 12 {
|
||||
result.RecordsError++
|
||||
result.Errors = append(result.Errors, "insufficient fields in airport record")
|
||||
continue
|
||||
}
|
||||
|
||||
result.RecordsTotal++
|
||||
|
||||
// Parse OpenFlights airport format
|
||||
id, _ := strconv.Atoi(record[0])
|
||||
name := strings.Trim(record[1], `"`)
|
||||
city := strings.Trim(record[2], `"`)
|
||||
country := strings.Trim(record[3], `"`)
|
||||
iata := strings.Trim(record[4], `"`)
|
||||
icao := strings.Trim(record[5], `"`)
|
||||
lat, _ := strconv.ParseFloat(record[6], 64)
|
||||
lon, _ := strconv.ParseFloat(record[7], 64)
|
||||
alt, _ := strconv.Atoi(record[8])
|
||||
tzOffset, _ := strconv.ParseFloat(record[9], 64)
|
||||
dst := strings.Trim(record[10], `"`)
|
||||
timezone := strings.Trim(record[11], `"`)
|
||||
|
||||
// Convert \N to empty strings
|
||||
if iata == "\\N" { iata = "" }
|
||||
if icao == "\\N" { icao = "" }
|
||||
if dst == "\\N" { dst = "" }
|
||||
if timezone == "\\N" { timezone = "" }
|
||||
|
||||
_, err = insertStmt.Exec(id, name, city, country, iata, icao, lat, lon, alt, tzOffset, dst, timezone, source.Name)
|
||||
if err != nil {
|
||||
result.RecordsError++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("insert error for airport %s: %v", name, err))
|
||||
continue
|
||||
}
|
||||
|
||||
result.RecordsNew++
|
||||
}
|
||||
|
||||
// Update record count
|
||||
_, err = tx.Exec(`UPDATE data_sources SET record_count = ? WHERE name = ?`, result.RecordsNew, source.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update record count: %v", err)
|
||||
}
|
||||
|
||||
return result, tx.Commit()
|
||||
}
|
||||
|
||||
// loadOurAirports loads airport data in OurAirports CSV format
|
||||
func (dl *DataLoader) loadOurAirports(reader io.Reader, source DataSource, result *LoadResult) (*LoadResult, error) {
|
||||
// Start database transaction
|
||||
tx, err := dl.conn.Begin()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin transaction: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
csvReader := csv.NewReader(reader)
|
||||
|
||||
// Read header row
|
||||
headers, err := csvReader.Read()
|
||||
if err != nil {
|
||||
result.RecordsError = 1
|
||||
result.Errors = []string{fmt.Sprintf("Failed to read CSV header: %v", err)}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Create header index map for easier field access
|
||||
headerIndex := make(map[string]int)
|
||||
for i, header := range headers {
|
||||
headerIndex[strings.TrimSpace(header)] = i
|
||||
}
|
||||
|
||||
// Prepare statement for airports
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT OR REPLACE INTO airports (
|
||||
source_id, name, ident, type, icao_code, iata_code,
|
||||
latitude, longitude, elevation_ft, country_code,
|
||||
municipality, continent, scheduled_service,
|
||||
home_link, wikipedia_link, keywords, data_source
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
result.RecordsError = 1
|
||||
result.Errors = []string{fmt.Sprintf("Failed to prepare statement: %v", err)}
|
||||
return result, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
// Process each row
|
||||
for {
|
||||
record, err := csvReader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
result.RecordsError++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("CSV read error: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip rows with insufficient fields
|
||||
if len(record) < len(headerIndex) {
|
||||
result.RecordsError++
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract fields using header index
|
||||
sourceID := getFieldByHeader(record, headerIndex, "id")
|
||||
ident := getFieldByHeader(record, headerIndex, "ident")
|
||||
name := getFieldByHeader(record, headerIndex, "name")
|
||||
icaoCode := getFieldByHeader(record, headerIndex, "icao_code")
|
||||
iataCode := getFieldByHeader(record, headerIndex, "iata_code")
|
||||
airportType := getFieldByHeader(record, headerIndex, "type")
|
||||
countryCode := getFieldByHeader(record, headerIndex, "iso_country")
|
||||
municipality := getFieldByHeader(record, headerIndex, "municipality")
|
||||
continent := getFieldByHeader(record, headerIndex, "continent")
|
||||
homeLink := getFieldByHeader(record, headerIndex, "home_link")
|
||||
wikipediaLink := getFieldByHeader(record, headerIndex, "wikipedia_link")
|
||||
keywords := getFieldByHeader(record, headerIndex, "keywords")
|
||||
|
||||
// Parse coordinates
|
||||
var latitude, longitude float64
|
||||
if latStr := getFieldByHeader(record, headerIndex, "latitude_deg"); latStr != "" {
|
||||
if lat, err := strconv.ParseFloat(latStr, 64); err == nil {
|
||||
latitude = lat
|
||||
}
|
||||
}
|
||||
if lngStr := getFieldByHeader(record, headerIndex, "longitude_deg"); lngStr != "" {
|
||||
if lng, err := strconv.ParseFloat(lngStr, 64); err == nil {
|
||||
longitude = lng
|
||||
}
|
||||
}
|
||||
|
||||
// Parse elevation
|
||||
var elevation int
|
||||
if elevStr := getFieldByHeader(record, headerIndex, "elevation_ft"); elevStr != "" {
|
||||
if elev, err := strconv.Atoi(elevStr); err == nil {
|
||||
elevation = elev
|
||||
}
|
||||
}
|
||||
|
||||
// Parse scheduled service
|
||||
scheduledService := getFieldByHeader(record, headerIndex, "scheduled_service") == "yes"
|
||||
|
||||
// Insert airport record
|
||||
_, err = stmt.Exec(
|
||||
sourceID, name, ident, airportType, icaoCode, iataCode,
|
||||
latitude, longitude, elevation, countryCode, municipality, continent,
|
||||
scheduledService, homeLink, wikipediaLink, keywords, source.Name,
|
||||
)
|
||||
if err != nil {
|
||||
result.RecordsError++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Insert error for %s: %v", ident, err))
|
||||
} else {
|
||||
result.RecordsNew++
|
||||
}
|
||||
}
|
||||
|
||||
// Update data source tracking
|
||||
_, err = tx.Exec(`
|
||||
INSERT OR REPLACE INTO data_sources (name, license, url, imported_at, record_count, user_accepted_license)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?)
|
||||
`, source.Name, source.License, source.URL, result.RecordsNew, source.UserAcceptedLicense)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to update data source tracking: %v", err)
|
||||
}
|
||||
|
||||
return result, tx.Commit()
|
||||
}
|
||||
|
||||
// getFieldByHeader safely gets a field value by header name
|
||||
func getFieldByHeader(record []string, headerIndex map[string]int, fieldName string) string {
|
||||
if idx, exists := headerIndex[fieldName]; exists && idx < len(record) {
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetLoadedDataSources returns all data sources that have been imported
|
||||
func (dl *DataLoader) GetLoadedDataSources() ([]DataSource, error) {
|
||||
query := `
|
||||
SELECT name, license, url, COALESCE(version, 'latest'), user_accepted_license
|
||||
FROM data_sources
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := dl.conn.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sources []DataSource
|
||||
for rows.Next() {
|
||||
var source DataSource
|
||||
err := rows.Scan(
|
||||
&source.Name,
|
||||
&source.License,
|
||||
&source.URL,
|
||||
&source.Version,
|
||||
&source.UserAcceptedLicense,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources = append(sources, source)
|
||||
}
|
||||
|
||||
return sources, rows.Err()
|
||||
}
|
||||
|
||||
// recordDataSource records information about the data source being imported
|
||||
func (dl *DataLoader) recordDataSource(tx *sql.Tx, source DataSource) error {
|
||||
_, err := tx.Exec(`
|
||||
INSERT OR REPLACE INTO data_sources
|
||||
(name, license, url, version, user_accepted_license)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, source.Name, source.License, source.URL, source.Version, source.UserAcceptedLicense)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
// ClearDataSource removes all data from a specific source
|
||||
func (dl *DataLoader) ClearDataSource(sourceName string) error {
|
||||
tx, err := dl.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Clear from all tables
|
||||
_, err = tx.Exec(`DELETE FROM airlines WHERE data_source = ?`, sourceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clear airlines: %v", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`DELETE FROM airports WHERE data_source = ?`, sourceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clear airports: %v", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`DELETE FROM data_sources WHERE name = ?`, sourceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clear data source record: %v", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
364
internal/database/manager_callsign.go
Normal file
364
internal/database/manager_callsign.go
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
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 original_callsign, airline_code, flight_number, airline_name,
|
||||
airline_country, display_name, is_valid, last_updated, cache_expires
|
||||
FROM callsign_cache
|
||||
WHERE original_callsign = ? AND cache_expires > 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
|
||||
(original_callsign, airline_code, flight_number, airline_name,
|
||||
airline_country, display_name, is_valid, last_updated, cache_expires)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := cm.db.Exec(query,
|
||||
info.OriginalCallsign,
|
||||
info.AirlineCode,
|
||||
info.FlightNumber,
|
||||
info.AirlineName,
|
||||
info.AirlineCountry,
|
||||
info.DisplayName,
|
||||
info.IsValid,
|
||||
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 cache_expires <= 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 cache_expires > 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
|
||||
}
|
||||
411
internal/database/manager_history.go
Normal file
411
internal/database/manager_history.go
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HistoryManager struct {
|
||||
db *sql.DB
|
||||
mutex sync.RWMutex
|
||||
|
||||
// Configuration
|
||||
maxHistoryDays int
|
||||
cleanupTicker *time.Ticker
|
||||
stopCleanup chan bool
|
||||
}
|
||||
|
||||
func NewHistoryManager(db *sql.DB, maxHistoryDays int) *HistoryManager {
|
||||
hm := &HistoryManager{
|
||||
db: db,
|
||||
maxHistoryDays: maxHistoryDays,
|
||||
stopCleanup: make(chan bool),
|
||||
}
|
||||
|
||||
// Start periodic cleanup (every hour)
|
||||
hm.cleanupTicker = time.NewTicker(1 * time.Hour)
|
||||
go hm.periodicCleanup()
|
||||
|
||||
return hm
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) Close() {
|
||||
if hm.cleanupTicker != nil {
|
||||
hm.cleanupTicker.Stop()
|
||||
}
|
||||
if hm.stopCleanup != nil {
|
||||
close(hm.stopCleanup)
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) periodicCleanup() {
|
||||
for {
|
||||
select {
|
||||
case <-hm.cleanupTicker.C:
|
||||
if err := hm.CleanupOldHistory(); err != nil {
|
||||
fmt.Printf("Warning: failed to cleanup old history: %v\n", err)
|
||||
}
|
||||
case <-hm.stopCleanup:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) RecordAircraft(record *AircraftHistoryRecord) error {
|
||||
hm.mutex.Lock()
|
||||
defer hm.mutex.Unlock()
|
||||
|
||||
query := `
|
||||
INSERT INTO aircraft_history
|
||||
(icao, callsign, squawk, latitude, longitude, altitude,
|
||||
vertical_rate, speed, track, source_id, signal_strength, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := hm.db.Exec(query,
|
||||
record.ICAO,
|
||||
record.Callsign,
|
||||
record.Squawk,
|
||||
record.Latitude,
|
||||
record.Longitude,
|
||||
record.Altitude,
|
||||
record.VerticalRate,
|
||||
record.Speed,
|
||||
record.Track,
|
||||
record.SourceID,
|
||||
record.SignalStrength,
|
||||
record.Timestamp,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) RecordAircraftBatch(records []AircraftHistoryRecord) error {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hm.mutex.Lock()
|
||||
defer hm.mutex.Unlock()
|
||||
|
||||
tx, err := hm.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO aircraft_history
|
||||
(icao, callsign, squawk, latitude, longitude, altitude,
|
||||
vertical_rate, speed, track, source_id, signal_strength, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, record := range records {
|
||||
_, err := stmt.Exec(
|
||||
record.ICAO,
|
||||
record.Callsign,
|
||||
record.Squawk,
|
||||
record.Latitude,
|
||||
record.Longitude,
|
||||
record.Altitude,
|
||||
record.VerticalRate,
|
||||
record.Speed,
|
||||
record.Track,
|
||||
record.SourceID,
|
||||
record.SignalStrength,
|
||||
record.Timestamp,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert record for ICAO %s: %w", record.ICAO, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) GetAircraftHistory(icao string, hours int) ([]AircraftHistoryRecord, error) {
|
||||
hm.mutex.RLock()
|
||||
defer hm.mutex.RUnlock()
|
||||
|
||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
query := `
|
||||
SELECT icao, callsign, squawk, latitude, longitude, altitude,
|
||||
vertical_rate, speed, track, source_id, signal_strength, timestamp
|
||||
FROM aircraft_history
|
||||
WHERE icao = ? AND timestamp >= ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1000
|
||||
`
|
||||
|
||||
rows, err := hm.db.Query(query, icao, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []AircraftHistoryRecord
|
||||
for rows.Next() {
|
||||
var record AircraftHistoryRecord
|
||||
err := rows.Scan(
|
||||
&record.ICAO,
|
||||
&record.Callsign,
|
||||
&record.Squawk,
|
||||
&record.Latitude,
|
||||
&record.Longitude,
|
||||
&record.Altitude,
|
||||
&record.VerticalRate,
|
||||
&record.Speed,
|
||||
&record.Track,
|
||||
&record.SourceID,
|
||||
&record.SignalStrength,
|
||||
&record.Timestamp,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) GetAircraftTrack(icao string, hours int) ([]TrackPoint, error) {
|
||||
hm.mutex.RLock()
|
||||
defer hm.mutex.RUnlock()
|
||||
|
||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
query := `
|
||||
SELECT latitude, longitude, altitude, timestamp
|
||||
FROM aircraft_history
|
||||
WHERE icao = ? AND timestamp >= ?
|
||||
AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT 500
|
||||
`
|
||||
|
||||
rows, err := hm.db.Query(query, icao, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var track []TrackPoint
|
||||
for rows.Next() {
|
||||
var point TrackPoint
|
||||
err := rows.Scan(
|
||||
&point.Latitude,
|
||||
&point.Longitude,
|
||||
&point.Altitude,
|
||||
&point.Timestamp,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
track = append(track, point)
|
||||
}
|
||||
|
||||
return track, rows.Err()
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) GetRecentAircraft(hours int, limit int) ([]string, error) {
|
||||
hm.mutex.RLock()
|
||||
defer hm.mutex.RUnlock()
|
||||
|
||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
query := `
|
||||
SELECT DISTINCT icao
|
||||
FROM aircraft_history
|
||||
WHERE timestamp >= ?
|
||||
ORDER BY MAX(timestamp) DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
rows, err := hm.db.Query(query, since, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var icaos []string
|
||||
for rows.Next() {
|
||||
var icao string
|
||||
err := rows.Scan(&icao)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
icaos = append(icaos, icao)
|
||||
}
|
||||
|
||||
return icaos, rows.Err()
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) GetAircraftLastSeen(icao string) (time.Time, error) {
|
||||
hm.mutex.RLock()
|
||||
defer hm.mutex.RUnlock()
|
||||
|
||||
query := `
|
||||
SELECT MAX(timestamp)
|
||||
FROM aircraft_history
|
||||
WHERE icao = ?
|
||||
`
|
||||
|
||||
var lastSeen time.Time
|
||||
err := hm.db.QueryRow(query, icao).Scan(&lastSeen)
|
||||
return lastSeen, err
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) CleanupOldHistory() error {
|
||||
hm.mutex.Lock()
|
||||
defer hm.mutex.Unlock()
|
||||
|
||||
if hm.maxHistoryDays <= 0 {
|
||||
return nil // No cleanup if maxHistoryDays is 0 or negative
|
||||
}
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -hm.maxHistoryDays)
|
||||
|
||||
query := `DELETE FROM aircraft_history WHERE timestamp < ?`
|
||||
result, err := hm.db.Exec(query, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err == nil && rowsAffected > 0 {
|
||||
fmt.Printf("Cleaned up %d old aircraft history records\n", rowsAffected)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) GetStatistics() (map[string]interface{}, error) {
|
||||
return hm.GetHistoryStats()
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) GetHistoryStats() (map[string]interface{}, error) {
|
||||
hm.mutex.RLock()
|
||||
defer hm.mutex.RUnlock()
|
||||
|
||||
stats := make(map[string]interface{})
|
||||
|
||||
// Total records
|
||||
var totalRecords int
|
||||
err := hm.db.QueryRow(`SELECT COUNT(*) FROM aircraft_history`).Scan(&totalRecords)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["total_records"] = totalRecords
|
||||
|
||||
// Unique aircraft
|
||||
var uniqueAircraft int
|
||||
err = hm.db.QueryRow(`SELECT COUNT(DISTINCT icao) FROM aircraft_history`).Scan(&uniqueAircraft)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["unique_aircraft"] = uniqueAircraft
|
||||
|
||||
// Recent records (last 24 hours)
|
||||
var recentRecords int
|
||||
since := time.Now().Add(-24 * time.Hour)
|
||||
err = hm.db.QueryRow(`SELECT COUNT(*) FROM aircraft_history WHERE timestamp >= ?`, since).Scan(&recentRecords)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["recent_records_24h"] = recentRecords
|
||||
|
||||
// Oldest and newest record timestamps (only if records exist)
|
||||
if totalRecords > 0 {
|
||||
var oldestTimestamp, newestTimestamp time.Time
|
||||
err = hm.db.QueryRow(`SELECT MIN(timestamp), MAX(timestamp) FROM aircraft_history`).Scan(&oldestTimestamp, &newestTimestamp)
|
||||
if err == nil {
|
||||
stats["oldest_record"] = oldestTimestamp
|
||||
stats["newest_record"] = newestTimestamp
|
||||
stats["history_days"] = int(time.Since(oldestTimestamp).Hours() / 24)
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (hm *HistoryManager) GetActivitySummary(hours int) (map[string]interface{}, error) {
|
||||
hm.mutex.RLock()
|
||||
defer hm.mutex.RUnlock()
|
||||
|
||||
since := time.Now().Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
summary := make(map[string]interface{})
|
||||
|
||||
// Aircraft count in time period
|
||||
var aircraftCount int
|
||||
err := hm.db.QueryRow(`
|
||||
SELECT COUNT(DISTINCT icao)
|
||||
FROM aircraft_history
|
||||
WHERE timestamp >= ?
|
||||
`, since).Scan(&aircraftCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary["aircraft_count"] = aircraftCount
|
||||
|
||||
// Message count in time period
|
||||
var messageCount int
|
||||
err = hm.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM aircraft_history
|
||||
WHERE timestamp >= ?
|
||||
`, since).Scan(&messageCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary["message_count"] = messageCount
|
||||
|
||||
// Most active sources
|
||||
query := `
|
||||
SELECT source_id, COUNT(*) as count
|
||||
FROM aircraft_history
|
||||
WHERE timestamp >= ?
|
||||
GROUP BY source_id
|
||||
ORDER BY count DESC
|
||||
LIMIT 5
|
||||
`
|
||||
|
||||
rows, err := hm.db.Query(query, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
sources := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var sourceID string
|
||||
var count int
|
||||
err := rows.Scan(&sourceID, &count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources = append(sources, map[string]interface{}{
|
||||
"source_id": sourceID,
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
summary["top_sources"] = sources
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
type TrackPoint struct {
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Altitude *int `json:"altitude,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
419
internal/database/migrations.go
Normal file
419
internal/database/migrations.go
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Migrator handles database schema migrations with rollback support
|
||||
type Migrator struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
// Migration represents a database schema change
|
||||
type Migration struct {
|
||||
Version int
|
||||
Description string
|
||||
Up string
|
||||
Down string
|
||||
DataLoss bool
|
||||
Checksum string
|
||||
}
|
||||
|
||||
// MigrationRecord represents a completed migration in the database
|
||||
type MigrationRecord struct {
|
||||
Version int `json:"version"`
|
||||
Description string `json:"description"`
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
Checksum string `json:"checksum"`
|
||||
}
|
||||
|
||||
// NewMigrator creates a new database migrator
|
||||
func NewMigrator(conn *sql.DB) *Migrator {
|
||||
return &Migrator{conn: conn}
|
||||
}
|
||||
|
||||
// GetMigrations returns all available migrations in version order
|
||||
func GetMigrations() []Migration {
|
||||
migrations := []Migration{
|
||||
{
|
||||
Version: 1,
|
||||
Description: "Initial schema with aircraft history",
|
||||
Up: `
|
||||
-- Schema metadata table
|
||||
CREATE TABLE IF NOT EXISTS schema_info (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
description TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Aircraft position history
|
||||
CREATE TABLE IF NOT EXISTS aircraft_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
icao TEXT NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
altitude INTEGER,
|
||||
speed INTEGER,
|
||||
track INTEGER,
|
||||
vertical_rate INTEGER,
|
||||
squawk TEXT,
|
||||
callsign TEXT,
|
||||
source_id TEXT NOT NULL,
|
||||
signal_strength REAL
|
||||
);
|
||||
|
||||
-- Indexes for aircraft history
|
||||
CREATE INDEX IF NOT EXISTS idx_aircraft_history_icao_time ON aircraft_history(icao, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_aircraft_history_timestamp ON aircraft_history(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_aircraft_history_callsign ON aircraft_history(callsign);
|
||||
`,
|
||||
Down: `
|
||||
DROP TABLE IF EXISTS aircraft_history;
|
||||
DROP TABLE IF EXISTS schema_info;
|
||||
`,
|
||||
DataLoss: true,
|
||||
},
|
||||
{
|
||||
Version: 2,
|
||||
Description: "Add callsign enhancement tables",
|
||||
Up: `
|
||||
-- Airlines data table (unified schema for all sources)
|
||||
CREATE TABLE IF NOT EXISTS airlines (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
alias TEXT,
|
||||
iata_code TEXT,
|
||||
icao_code TEXT,
|
||||
callsign TEXT,
|
||||
country TEXT,
|
||||
country_code TEXT,
|
||||
active BOOLEAN DEFAULT 1,
|
||||
data_source TEXT NOT NULL DEFAULT 'unknown',
|
||||
source_id TEXT, -- Original ID from source
|
||||
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Airports data table (unified schema for all sources)
|
||||
CREATE TABLE IF NOT EXISTS airports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
ident TEXT, -- Airport identifier (ICAO, FAA, etc.)
|
||||
type TEXT, -- Airport type (large_airport, medium_airport, etc.)
|
||||
city TEXT,
|
||||
municipality TEXT, -- More specific than city
|
||||
region TEXT, -- State/province
|
||||
country TEXT,
|
||||
country_code TEXT, -- ISO country code
|
||||
continent TEXT,
|
||||
iata_code TEXT,
|
||||
icao_code TEXT,
|
||||
local_code TEXT,
|
||||
gps_code TEXT,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
elevation_ft INTEGER,
|
||||
scheduled_service BOOLEAN DEFAULT 0,
|
||||
home_link TEXT,
|
||||
wikipedia_link TEXT,
|
||||
keywords TEXT,
|
||||
timezone_offset REAL,
|
||||
timezone TEXT,
|
||||
dst_type TEXT,
|
||||
data_source TEXT NOT NULL DEFAULT 'unknown',
|
||||
source_id TEXT, -- Original ID from source
|
||||
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- External API cache for callsign lookups
|
||||
CREATE TABLE IF NOT EXISTS callsign_cache (
|
||||
callsign TEXT PRIMARY KEY,
|
||||
airline_icao TEXT,
|
||||
airline_iata TEXT,
|
||||
airline_name TEXT,
|
||||
airline_country TEXT,
|
||||
flight_number TEXT,
|
||||
origin_iata TEXT,
|
||||
destination_iata TEXT,
|
||||
aircraft_type TEXT,
|
||||
route TEXT,
|
||||
status TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'local',
|
||||
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Data source tracking
|
||||
CREATE TABLE IF NOT EXISTS data_sources (
|
||||
name TEXT PRIMARY KEY,
|
||||
license TEXT NOT NULL,
|
||||
url TEXT,
|
||||
version TEXT,
|
||||
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
record_count INTEGER DEFAULT 0,
|
||||
user_accepted_license BOOLEAN DEFAULT 0
|
||||
);
|
||||
|
||||
-- Indexes for airlines
|
||||
CREATE INDEX IF NOT EXISTS idx_airlines_icao_code ON airlines(icao_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_airlines_iata_code ON airlines(iata_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_airlines_callsign ON airlines(callsign);
|
||||
CREATE INDEX IF NOT EXISTS idx_airlines_country_code ON airlines(country_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_airlines_active ON airlines(active);
|
||||
CREATE INDEX IF NOT EXISTS idx_airlines_source ON airlines(data_source);
|
||||
|
||||
-- Indexes for airports
|
||||
CREATE INDEX IF NOT EXISTS idx_airports_icao_code ON airports(icao_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_airports_iata_code ON airports(iata_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_airports_ident ON airports(ident);
|
||||
CREATE INDEX IF NOT EXISTS idx_airports_country_code ON airports(country_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_airports_type ON airports(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_airports_coords ON airports(latitude, longitude);
|
||||
CREATE INDEX IF NOT EXISTS idx_airports_source ON airports(data_source);
|
||||
|
||||
-- Indexes for callsign cache
|
||||
CREATE INDEX IF NOT EXISTS idx_callsign_cache_expires ON callsign_cache(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_callsign_cache_airline ON callsign_cache(airline_icao);
|
||||
`,
|
||||
Down: `
|
||||
DROP TABLE IF EXISTS callsign_cache;
|
||||
DROP TABLE IF EXISTS airports;
|
||||
DROP TABLE IF EXISTS airlines;
|
||||
DROP TABLE IF EXISTS data_sources;
|
||||
`,
|
||||
DataLoss: true,
|
||||
},
|
||||
// Future migrations will be added here
|
||||
}
|
||||
|
||||
// Calculate checksums
|
||||
for i := range migrations {
|
||||
migrations[i].Checksum = calculateChecksum(migrations[i].Up)
|
||||
}
|
||||
|
||||
// Sort by version
|
||||
sort.Slice(migrations, func(i, j int) bool {
|
||||
return migrations[i].Version < migrations[j].Version
|
||||
})
|
||||
|
||||
return migrations
|
||||
}
|
||||
|
||||
// MigrateToLatest runs all pending migrations to bring database to latest schema
|
||||
func (m *Migrator) MigrateToLatest() error {
|
||||
currentVersion, err := m.getCurrentVersion()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current version: %v", err)
|
||||
}
|
||||
|
||||
migrations := GetMigrations()
|
||||
|
||||
for _, migration := range migrations {
|
||||
if migration.Version <= currentVersion {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := m.applyMigration(migration); err != nil {
|
||||
return fmt.Errorf("failed to apply migration %d: %v", migration.Version, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateTo runs migrations to reach a specific version
|
||||
func (m *Migrator) MigrateTo(targetVersion int) error {
|
||||
currentVersion, err := m.getCurrentVersion()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current version: %v", err)
|
||||
}
|
||||
|
||||
if targetVersion == currentVersion {
|
||||
return nil // Already at target version
|
||||
}
|
||||
|
||||
migrations := GetMigrations()
|
||||
|
||||
if targetVersion > currentVersion {
|
||||
// Forward migration
|
||||
for _, migration := range migrations {
|
||||
if migration.Version <= currentVersion || migration.Version > targetVersion {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := m.applyMigration(migration); err != nil {
|
||||
return fmt.Errorf("failed to apply migration %d: %v", migration.Version, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Rollback migration
|
||||
// Sort in reverse order for rollback
|
||||
sort.Slice(migrations, func(i, j int) bool {
|
||||
return migrations[i].Version > migrations[j].Version
|
||||
})
|
||||
|
||||
for _, migration := range migrations {
|
||||
if migration.Version > currentVersion || migration.Version <= targetVersion {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := m.rollbackMigration(migration); err != nil {
|
||||
return fmt.Errorf("failed to rollback migration %d: %v", migration.Version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAppliedMigrations returns all migrations that have been applied
|
||||
func (m *Migrator) GetAppliedMigrations() ([]MigrationRecord, error) {
|
||||
// Ensure schema_info table exists
|
||||
if err := m.ensureSchemaInfoTable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT version, description, applied_at, checksum
|
||||
FROM schema_info
|
||||
ORDER BY version
|
||||
`
|
||||
|
||||
rows, err := m.conn.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query applied migrations: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var migrations []MigrationRecord
|
||||
for rows.Next() {
|
||||
var migration MigrationRecord
|
||||
err := rows.Scan(
|
||||
&migration.Version,
|
||||
&migration.Description,
|
||||
&migration.AppliedAt,
|
||||
&migration.Checksum,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan migration record: %v", err)
|
||||
}
|
||||
migrations = append(migrations, migration)
|
||||
}
|
||||
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
// getCurrentVersion returns the highest applied migration version
|
||||
func (m *Migrator) getCurrentVersion() (int, error) {
|
||||
if err := m.ensureSchemaInfoTable(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var version int
|
||||
err := m.conn.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_info`).Scan(&version)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get current version: %v", err)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// applyMigration executes a migration in a transaction
|
||||
func (m *Migrator) applyMigration(migration Migration) error {
|
||||
tx, err := m.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Warn about data loss
|
||||
if migration.DataLoss {
|
||||
// In a real application, this would show a warning to the user
|
||||
// For now, we'll just log it
|
||||
}
|
||||
|
||||
// Execute migration SQL
|
||||
statements := strings.Split(migration.Up, ";")
|
||||
for _, stmt := range statements {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("failed to execute migration statement: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Record migration
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO schema_info (version, description, checksum)
|
||||
VALUES (?, ?, ?)
|
||||
`, migration.Version, migration.Description, migration.Checksum)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record migration: %v", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// rollbackMigration executes a migration rollback in a transaction
|
||||
func (m *Migrator) rollbackMigration(migration Migration) error {
|
||||
if migration.Down == "" {
|
||||
return fmt.Errorf("migration %d has no rollback script", migration.Version)
|
||||
}
|
||||
|
||||
tx, err := m.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %v", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Execute rollback SQL
|
||||
statements := strings.Split(migration.Down, ";")
|
||||
for _, stmt := range statements {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("failed to execute rollback statement: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove migration record
|
||||
_, err = tx.Exec(`DELETE FROM schema_info WHERE version = ?`, migration.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove migration record: %v", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ensureSchemaInfoTable creates the schema_info table if it doesn't exist
|
||||
func (m *Migrator) ensureSchemaInfoTable() error {
|
||||
_, err := m.conn.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_info (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
description TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// calculateChecksum generates a checksum for migration content
|
||||
func calculateChecksum(content string) string {
|
||||
// Simple checksum - in production, use a proper hash function
|
||||
return fmt.Sprintf("%x", len(content))
|
||||
}
|
||||
174
internal/database/path.go
Normal file
174
internal/database/path.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// ResolveDatabasePath determines the appropriate database file location
|
||||
// based on configuration, system type, and available permissions
|
||||
func ResolveDatabasePath(configPath string) (string, error) {
|
||||
// Use explicit configuration path if provided
|
||||
if configPath != "" {
|
||||
if err := ensureDirExists(filepath.Dir(configPath)); err != nil {
|
||||
return "", fmt.Errorf("cannot create directory for configured path %s: %v", configPath, err)
|
||||
}
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
// Try system location first (for services)
|
||||
if systemPath, err := trySystemPath(); err == nil {
|
||||
return systemPath, nil
|
||||
}
|
||||
|
||||
// Try user data directory
|
||||
if userPath, err := tryUserPath(); err == nil {
|
||||
return userPath, nil
|
||||
}
|
||||
|
||||
// Fallback to current directory
|
||||
return tryCurrentDirPath()
|
||||
}
|
||||
|
||||
// trySystemPath attempts to use system-wide database location
|
||||
func trySystemPath() (string, error) {
|
||||
var systemDir string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
systemDir = "/var/lib/skyview"
|
||||
case "darwin":
|
||||
systemDir = "/usr/local/var/skyview"
|
||||
case "windows":
|
||||
systemDir = filepath.Join(os.Getenv("PROGRAMDATA"), "skyview")
|
||||
default:
|
||||
return "", fmt.Errorf("system path not supported on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// Check if directory exists and is writable
|
||||
if err := ensureDirExists(systemDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(systemDir, "skyview.db")
|
||||
|
||||
// Test write permissions
|
||||
if err := testWritePermissions(dbPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return dbPath, nil
|
||||
}
|
||||
|
||||
// tryUserPath attempts to use user data directory
|
||||
func tryUserPath() (string, error) {
|
||||
var userDataDir string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" {
|
||||
userDataDir = xdgData
|
||||
} else {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
userDataDir = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
case "darwin":
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
userDataDir = filepath.Join(home, "Library", "Application Support")
|
||||
case "windows":
|
||||
userDataDir = os.Getenv("APPDATA")
|
||||
if userDataDir == "" {
|
||||
return "", fmt.Errorf("APPDATA environment variable not set")
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("user path not supported on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
skyviewDir := filepath.Join(userDataDir, "skyview")
|
||||
|
||||
if err := ensureDirExists(skyviewDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(skyviewDir, "skyview.db")
|
||||
|
||||
// Test write permissions
|
||||
if err := testWritePermissions(dbPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return dbPath, nil
|
||||
}
|
||||
|
||||
// tryCurrentDirPath uses current directory as fallback
|
||||
func tryCurrentDirPath() (string, error) {
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot get current directory: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(currentDir, "skyview.db")
|
||||
|
||||
// Test write permissions
|
||||
if err := testWritePermissions(dbPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return dbPath, nil
|
||||
}
|
||||
|
||||
// ensureDirExists creates directory if it doesn't exist
|
||||
func ensureDirExists(dir string) error {
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("cannot create directory %s: %v", dir, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("cannot access directory %s: %v", dir, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// testWritePermissions verifies write access to the database path
|
||||
func testWritePermissions(dbPath string) error {
|
||||
dir := filepath.Dir(dbPath)
|
||||
|
||||
// Check directory write permissions
|
||||
testFile := filepath.Join(dir, ".skyview_write_test")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
return fmt.Errorf("no write permission to directory %s: %v", dir, err)
|
||||
}
|
||||
|
||||
// Clean up test file
|
||||
os.Remove(testFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDatabaseDirectory returns the directory containing the database file
|
||||
func GetDatabaseDirectory(dbPath string) string {
|
||||
return filepath.Dir(dbPath)
|
||||
}
|
||||
|
||||
// IsSystemPath returns true if the database path is in a system location
|
||||
func IsSystemPath(dbPath string) bool {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return filepath.HasPrefix(dbPath, "/var/lib/skyview")
|
||||
case "darwin":
|
||||
return filepath.HasPrefix(dbPath, "/usr/local/var/skyview")
|
||||
case "windows":
|
||||
programData := os.Getenv("PROGRAMDATA")
|
||||
return programData != "" && filepath.HasPrefix(dbPath, filepath.Join(programData, "skyview"))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"skyview/internal/database"
|
||||
"skyview/internal/icao"
|
||||
"skyview/internal/modes"
|
||||
"skyview/internal/squawk"
|
||||
|
|
@ -272,6 +273,7 @@ type Merger struct {
|
|||
sources map[string]*Source // Source ID -> source information
|
||||
icaoDB *icao.Database // ICAO country lookup database
|
||||
squawkDB *squawk.Database // Transponder code lookup database
|
||||
db *database.Database // Optional persistent database
|
||||
mu sync.RWMutex // Protects all maps and slices
|
||||
historyLimit int // Maximum history points to retain
|
||||
staleTimeout time.Duration // Time before aircraft considered stale (15 seconds)
|
||||
|
|
@ -295,6 +297,23 @@ type updateMetric struct {
|
|||
//
|
||||
// The merger is ready for immediate use after creation.
|
||||
func NewMerger() (*Merger, error) {
|
||||
return NewMergerWithDatabase(nil)
|
||||
}
|
||||
|
||||
// NewMergerWithDatabase creates a new aircraft data merger with optional database support.
|
||||
//
|
||||
// If a database is provided, aircraft positions will be persisted to the database
|
||||
// for historical analysis and long-term tracking. The database parameter can be nil
|
||||
// to disable persistence.
|
||||
//
|
||||
// Default settings:
|
||||
// - History limit: 500 points per aircraft
|
||||
// - Stale timeout: 15 seconds
|
||||
// - Empty aircraft and source maps
|
||||
// - Update metrics tracking enabled
|
||||
//
|
||||
// The merger is ready for immediate use after creation.
|
||||
func NewMergerWithDatabase(db *database.Database) (*Merger, error) {
|
||||
icaoDB, err := icao.NewDatabase()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize ICAO database: %w", err)
|
||||
|
|
@ -307,6 +326,7 @@ func NewMerger() (*Merger, error) {
|
|||
sources: make(map[string]*Source),
|
||||
icaoDB: icaoDB,
|
||||
squawkDB: squawkDB,
|
||||
db: db,
|
||||
historyLimit: 500,
|
||||
staleTimeout: 15 * time.Second, // Aircraft timeout - reasonable for ADS-B tracking
|
||||
updateMetrics: make(map[uint32]*updateMetric),
|
||||
|
|
@ -428,6 +448,11 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
|
|||
|
||||
state.LastUpdate = timestamp
|
||||
state.TotalMessages++
|
||||
|
||||
// Persist to database if available and aircraft has position
|
||||
if m.db != nil && aircraft.Latitude != 0 && aircraft.Longitude != 0 {
|
||||
m.saveAircraftToDatabase(aircraft, sourceID, signal, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeAircraftData intelligently merges data from multiple sources with conflict resolution.
|
||||
|
|
@ -1048,6 +1073,49 @@ func (m *Merger) validatePosition(aircraft *modes.Aircraft, state *AircraftState
|
|||
return result
|
||||
}
|
||||
|
||||
// saveAircraftToDatabase persists aircraft position data to the database
|
||||
func (m *Merger) saveAircraftToDatabase(aircraft *modes.Aircraft, sourceID string, signal float64, timestamp time.Time) {
|
||||
// Convert ICAO24 to hex string
|
||||
icaoHex := fmt.Sprintf("%06X", aircraft.ICAO24)
|
||||
|
||||
// Prepare database record
|
||||
record := database.AircraftHistoryRecord{
|
||||
ICAO: icaoHex,
|
||||
Timestamp: timestamp,
|
||||
Latitude: &aircraft.Latitude,
|
||||
Longitude: &aircraft.Longitude,
|
||||
SourceID: sourceID,
|
||||
SignalStrength: &signal,
|
||||
}
|
||||
|
||||
// Add optional fields if available
|
||||
if aircraft.Altitude > 0 {
|
||||
record.Altitude = &aircraft.Altitude
|
||||
}
|
||||
if aircraft.GroundSpeed > 0 {
|
||||
record.Speed = &aircraft.GroundSpeed
|
||||
}
|
||||
if aircraft.Track >= 0 && aircraft.Track < 360 {
|
||||
record.Track = &aircraft.Track
|
||||
}
|
||||
if aircraft.VerticalRate != 0 {
|
||||
record.VerticalRate = &aircraft.VerticalRate
|
||||
}
|
||||
if aircraft.Squawk != "" && aircraft.Squawk != "0000" {
|
||||
record.Squawk = &aircraft.Squawk
|
||||
}
|
||||
if aircraft.Callsign != "" {
|
||||
record.Callsign = &aircraft.Callsign
|
||||
}
|
||||
|
||||
// Save to database (non-blocking to avoid slowing down real-time processing)
|
||||
go func() {
|
||||
if err := m.db.GetHistoryManager().RecordAircraft(&record); err != nil {
|
||||
log.Printf("Warning: Failed to save aircraft %s to database: %v", icaoHex, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Close closes the merger and releases resources
|
||||
func (m *Merger) Close() error {
|
||||
m.mu.Lock()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue