From 3a7d65eeb7fba4e4bc8f6c0649e09df9542b5c63 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 18 Dec 2025 10:41:32 -0800 Subject: [PATCH] AI: Add IATA resolution --- zig/README.md | 4 -- zig/build.zig.zon | 4 ++ zig/src/config.zig | 4 ++ zig/src/location/airports.zig | 131 ++++++++++++++++++++++++++++++++++ zig/src/location/resolver.zig | 27 ++++++- zig/src/main.zig | 19 ++++- 6 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 zig/src/location/airports.zig diff --git a/zig/README.md b/zig/README.md index 6b2c7db..9479b24 100644 --- a/zig/README.md +++ b/zig/README.md @@ -46,10 +46,6 @@ This directory contains comprehensive documentation for rewriting wttr.in in Zig - Based on location coordinates and timezone - Display in custom format output -7. **Airport Database** - - IATA airport code to coordinates mapping - - Use dedicated airport data instead of geocoding service - ## Documentation Files ### [TARGET_ARCHITECTURE.md](TARGET_ARCHITECTURE.md) ⭐ NEW diff --git a/zig/build.zig.zon b/zig/build.zig.zon index 9ebb342..13e8056 100644 --- a/zig/build.zig.zon +++ b/zig/build.zig.zon @@ -10,6 +10,10 @@ .url = "https://github.com/maxmind/libmaxminddb/archive/refs/tags/1.11.0.tar.gz", .hash = "N-V-__8AAAYyBQCd9x7qVVFKQIxi01UZ1K8ZFZFfTzj99CvX", }, + .openflights = .{ + .url = "https://github.com/jpatokal/openflights/archive/refs/heads/master.tar.gz", + .hash = "N-V-__8AAKQtFgNwqtlYfjcAZQB5M1Vqc6ZPqjHkEaMHsJoT", + }, }, .fingerprint = 0x710c2b57e81aa678, .minimum_zig_version = "0.15.0", diff --git a/zig/src/config.zig b/zig/src/config.zig index f55e42d..658e4aa 100644 --- a/zig/src/config.zig +++ b/zig/src/config.zig @@ -7,6 +7,7 @@ pub const Config = struct { cache_dir: []const u8, geolite_path: []const u8, geocache_file: ?[]const u8, + airports_dat_path: ?[]const u8, pub fn load(allocator: std.mem.Allocator) !Config { return Config{ @@ -24,6 +25,7 @@ pub const Config = struct { .cache_dir = std.process.getEnvVarOwned(allocator, "WTTR_CACHE_DIR") catch try allocator.dupe(u8, "/tmp/wttr-cache"), .geolite_path = std.process.getEnvVarOwned(allocator, "WTTR_GEOLITE_PATH") catch try allocator.dupe(u8, "./GeoLite2-City.mmdb"), .geocache_file = std.process.getEnvVarOwned(allocator, "WTTR_GEOCACHE_FILE") catch null, + .airports_dat_path = std.process.getEnvVarOwned(allocator, "WTTR_AIRPORTS_DAT") catch null, }; } @@ -32,6 +34,7 @@ pub const Config = struct { allocator.free(self.cache_dir); allocator.free(self.geolite_path); if (self.geocache_file) |f| allocator.free(f); + if (self.airports_dat_path) |f| allocator.free(f); } }; @@ -45,4 +48,5 @@ test "config loads defaults" { try std.testing.expectEqual(@as(usize, 10_000), cfg.cache_size); try std.testing.expectEqualStrings("./GeoLite2-City.mmdb", cfg.geolite_path); try std.testing.expect(cfg.geocache_file == null); + try std.testing.expect(cfg.airports_dat_path == null); } diff --git a/zig/src/location/airports.zig b/zig/src/location/airports.zig new file mode 100644 index 0000000..5783070 --- /dev/null +++ b/zig/src/location/airports.zig @@ -0,0 +1,131 @@ +const std = @import("std"); + +pub const Airport = struct { + iata: []const u8, + name: []const u8, + latitude: f64, + longitude: f64, +}; + +pub const AirportDB = struct { + allocator: std.mem.Allocator, + airports: std.StringHashMap(Airport), + + pub fn initFromFile(allocator: std.mem.Allocator, file_path: []const u8) !AirportDB { + const file = try std.fs.cwd().openFile(file_path, .{}); + defer file.close(); + + const csv_data = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); // 10MB max + defer allocator.free(csv_data); + + return try init(allocator, csv_data); + } + + pub fn init(allocator: std.mem.Allocator, csv_data: []const u8) !AirportDB { + var airports = std.StringHashMap(Airport).init(allocator); + + var lines = std.mem.splitScalar(u8, csv_data, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + + const airport = parseAirportLine(allocator, line) catch continue; + if (airport.iata.len == 3) { + try airports.put(airport.iata, airport); + } + } + + return AirportDB{ + .allocator = allocator, + .airports = airports, + }; + } + + pub fn deinit(self: *AirportDB) void { + var it = self.airports.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + self.allocator.free(entry.value_ptr.name); + } + self.airports.deinit(); + } + + pub fn lookup(self: *AirportDB, iata_code: []const u8) ?Airport { + return self.airports.get(iata_code); + } + + fn parseAirportLine(allocator: std.mem.Allocator, line: []const u8) !Airport { + // CSV format: ID,Name,City,Country,IATA,ICAO,Lat,Lon,... + var fields = std.mem.splitScalar(u8, line, ','); + + _ = fields.next() orelse return error.InvalidFormat; // ID + const name_quoted = fields.next() orelse return error.InvalidFormat; // Name + _ = fields.next() orelse return error.InvalidFormat; // City + _ = fields.next() orelse return error.InvalidFormat; // Country + const iata_quoted = fields.next() orelse return error.InvalidFormat; // IATA + _ = fields.next() orelse return error.InvalidFormat; // ICAO + const lat_str = fields.next() orelse return error.InvalidFormat; // Lat + const lon_str = fields.next() orelse return error.InvalidFormat; // Lon + + // Remove quotes from fields + const name = try unquote(allocator, name_quoted); + const iata = try unquote(allocator, iata_quoted); + + // Skip if IATA is "\\N" (null) + if (std.mem.eql(u8, iata, "\\N")) { + allocator.free(name); + allocator.free(iata); + return error.NoIATA; + } + + const lat = try std.fmt.parseFloat(f64, lat_str); + const lon = try std.fmt.parseFloat(f64, lon_str); + + return Airport{ + .iata = iata, + .name = name, + .latitude = lat, + .longitude = lon, + }; + } + + fn unquote(allocator: std.mem.Allocator, quoted: []const u8) ![]const u8 { + if (quoted.len >= 2 and quoted[0] == '"' and quoted[quoted.len - 1] == '"') { + return allocator.dupe(u8, quoted[1 .. quoted.len - 1]); + } + return allocator.dupe(u8, quoted); + } +}; + +test "parseAirportLine valid" { + const allocator = std.testing.allocator; + const line = "1,\"Goroka Airport\",\"Goroka\",\"Papua New Guinea\",\"GKA\",\"AYGA\",-6.081689834590001,145.391998291,5282,10,\"U\",\"Pacific/Port_Moresby\",\"airport\",\"OurAirports\""; + + const airport = try AirportDB.parseAirportLine(allocator, line); + defer allocator.free(airport.iata); + defer allocator.free(airport.name); + + try std.testing.expectEqualStrings("GKA", airport.iata); + try std.testing.expectEqualStrings("Goroka Airport", airport.name); + try std.testing.expectApproxEqAbs(@as(f64, -6.081689834590001), airport.latitude, 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, 145.391998291), airport.longitude, 0.0001); +} + +test "parseAirportLine with null IATA" { + const allocator = std.testing.allocator; + const line = "1,\"Test Airport\",\"City\",\"Country\",\"\\N\",\"ICAO\",0.0,0.0"; + + try std.testing.expectError(error.NoIATA, AirportDB.parseAirportLine(allocator, line)); +} + +test "AirportDB lookup" { + const allocator = std.testing.allocator; + const csv = "1,\"Munich Airport\",\"Munich\",\"Germany\",\"MUC\",\"EDDM\",48.353802,11.7861,1487,1,\"E\",\"Europe/Berlin\",\"airport\",\"OurAirports\""; + + var db = try AirportDB.init(allocator, csv); + defer db.deinit(); + + const result = db.lookup("MUC"); + try std.testing.expect(result != null); + try std.testing.expectEqualStrings("Munich Airport", result.?.name); + try std.testing.expectApproxEqAbs(@as(f64, 48.353802), result.?.latitude, 0.0001); +} diff --git a/zig/src/location/resolver.zig b/zig/src/location/resolver.zig index 576cef5..8f321cd 100644 --- a/zig/src/location/resolver.zig +++ b/zig/src/location/resolver.zig @@ -1,6 +1,7 @@ const std = @import("std"); const GeoIP = @import("geoip.zig").GeoIP; const GeoCache = @import("geocache.zig").GeoCache; +const AirportDB = @import("airports.zig").AirportDB; pub const Location = struct { name: []const u8, @@ -20,12 +21,14 @@ pub const Resolver = struct { allocator: std.mem.Allocator, geoip: ?*GeoIP, geocache: *GeoCache, + airports: ?*AirportDB, - pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geocache: *GeoCache) Resolver { + pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geocache: *GeoCache, airports: ?*AirportDB) Resolver { return .{ .allocator = allocator, .geoip = geoip, .geocache = geocache, + .airports = airports, }; } @@ -154,7 +157,24 @@ pub const Resolver = struct { } fn resolveAirport(self: *Resolver, code: []const u8) !Location { - // For now, treat as geocoded location + // Try airport database first + if (self.airports) |airports| { + // Convert to uppercase for lookup + var upper_code: [3]u8 = undefined; + for (code, 0..) |c, i| { + upper_code[i] = std.ascii.toUpper(c); + } + + if (airports.lookup(&upper_code)) |airport| { + return Location{ + .name = try self.allocator.dupe(u8, airport.name), + .latitude = airport.latitude, + .longitude = airport.longitude, + }; + } + } + + // Fall back to geocoding return self.resolveGeocoded(code); } @@ -196,6 +216,7 @@ test "resolver init" { const allocator = std.testing.allocator; var geocache = try GeoCache.init(allocator, null); defer geocache.deinit(); - const resolver = Resolver.init(allocator, null, &geocache); + const resolver = Resolver.init(allocator, null, &geocache, null); try std.testing.expect(resolver.geoip == null); + try std.testing.expect(resolver.airports == null); } diff --git a/zig/src/main.zig b/zig/src/main.zig index 187d8d5..c39e3e8 100644 --- a/zig/src/main.zig +++ b/zig/src/main.zig @@ -7,6 +7,7 @@ const Server = @import("http/server.zig").Server; const RateLimiter = @import("http/rate_limiter.zig").RateLimiter; const GeoIP = @import("location/geoip.zig").GeoIP; const GeoCache = @import("location/geocache.zig").GeoCache; +const AirportDB = @import("location/airports.zig").AirportDB; const Resolver = @import("location/resolver.zig").Resolver; pub const std_options: std.Options = .{ @@ -34,6 +35,9 @@ pub fn main() !void { } else { try stdout.print("Geocache: in-memory only\n", .{}); } + if (cfg.airports_dat_path) |f| { + try stdout.print("Airports database: {s}\n", .{f}); + } try stdout.flush(); // Initialize GeoIP database @@ -48,8 +52,20 @@ pub fn main() !void { var geocache = try GeoCache.init(allocator, cfg.geocache_file); defer geocache.deinit(); + // Initialize airports database + var airports_db: ?AirportDB = null; + if (cfg.airports_dat_path) |path| { + airports_db = AirportDB.initFromFile(allocator, path) catch |err| blk: { + std.log.warn("Failed to load airports database: {}", .{err}); + break :blk null; + }; + } + if (airports_db) |*db| { + defer db.deinit(); + } + // Initialize location resolver - var resolver = Resolver.init(allocator, &geoip, &geocache); + var resolver = Resolver.init(allocator, &geoip, &geocache, if (airports_db) |*db| db else null); var cache = try Cache.init(allocator, .{ .max_entries = cfg.cache_size, @@ -90,5 +106,6 @@ test { _ = @import("render/custom.zig"); _ = @import("location/geoip.zig"); _ = @import("location/geocache.zig"); + _ = @import("location/airports.zig"); _ = @import("location/resolver.zig"); }