diff --git a/zig/src/http/handler.zig b/zig/src/http/handler.zig index 415b5c5..889bfed 100644 --- a/zig/src/http/handler.zig +++ b/zig/src/http/handler.zig @@ -4,6 +4,9 @@ const Cache = @import("../cache/cache.zig").Cache; const WeatherProvider = @import("../weather/provider.zig").WeatherProvider; const ansi = @import("../render/ansi.zig"); const line = @import("../render/line.zig"); +const json = @import("../render/json.zig"); +const v2 = @import("../render/v2.zig"); +const custom = @import("../render/custom.zig"); const help = @import("help.zig"); pub const HandleWeatherOptions = struct { @@ -77,15 +80,26 @@ fn handleWeatherInternal( const query = try req.query(); const format = query.get("format"); - const output = if (format) |fmt| - try line.render(allocator, weather, fmt) - else - try ansi.render(allocator, weather, .{}); + + const output = if (format) |fmt| blk: { + if (std.mem.eql(u8, fmt, "j1")) { + res.content_type = .JSON; + break :blk try json.render(allocator, weather); + } else if (std.mem.eql(u8, fmt, "v2")) { + break :blk try v2.render(allocator, weather); + } else if (std.mem.startsWith(u8, fmt, "%")) { + break :blk try custom.render(allocator, weather, fmt); + } else { + break :blk try line.render(allocator, weather, fmt); + } + } else try ansi.render(allocator, weather, .{}); const ttl = 1000 + std.crypto.random.intRangeAtMost(u64, 0, 1000); try opts.cache.put(cache_key, output, ttl); - res.content_type = .TEXT; + if (res.content_type != .JSON) { + res.content_type = .TEXT; + } res.body = output; } diff --git a/zig/src/http/query.zig b/zig/src/http/query.zig new file mode 100644 index 0000000..0a27c73 --- /dev/null +++ b/zig/src/http/query.zig @@ -0,0 +1,88 @@ +const std = @import("std"); + +pub const QueryParams = struct { + format: ?[]const u8 = null, + lang: ?[]const u8 = null, + units: ?Units = null, + transparency: ?u8 = null, + + pub const Units = enum { + metric, + uscs, + }; + + pub fn parse(allocator: std.mem.Allocator, query_string: []const u8) !QueryParams { + var params = QueryParams{}; + var iter = std.mem.splitScalar(u8, query_string, '&'); + + while (iter.next()) |pair| { + if (pair.len == 0) continue; + + var kv = std.mem.splitScalar(u8, pair, '='); + const key = kv.next() orelse continue; + const value = kv.next(); + + if (std.mem.eql(u8, key, "format")) { + params.format = if (value) |v| try allocator.dupe(u8, v) else null; + } else if (std.mem.eql(u8, key, "lang")) { + params.lang = if (value) |v| try allocator.dupe(u8, v) else null; + } else if (std.mem.eql(u8, key, "u")) { + params.units = .uscs; + } else if (std.mem.eql(u8, key, "m")) { + params.units = .metric; + } else if (std.mem.eql(u8, key, "transparency")) { + if (value) |v| { + params.transparency = try std.fmt.parseInt(u8, v, 10); + } + } else if (std.mem.eql(u8, key, "t")) { + params.transparency = 150; + } + } + + return params; + } +}; + +test "parse empty query" { + const allocator = std.testing.allocator; + const params = try QueryParams.parse(allocator, ""); + try std.testing.expect(params.format == null); + try std.testing.expect(params.lang == null); + try std.testing.expect(params.units == null); +} + +test "parse format parameter" { + const allocator = std.testing.allocator; + const params = try QueryParams.parse(allocator, "format=j1"); + defer if (params.format) |f| allocator.free(f); + try std.testing.expect(params.format != null); + try std.testing.expectEqualStrings("j1", params.format.?); +} + +test "parse units parameters" { + const allocator = std.testing.allocator; + const params_m = try QueryParams.parse(allocator, "m"); + try std.testing.expectEqual(QueryParams.Units.metric, params_m.units.?); + + const params_u = try QueryParams.parse(allocator, "u"); + try std.testing.expectEqual(QueryParams.Units.uscs, params_u.units.?); +} + +test "parse multiple parameters" { + const allocator = std.testing.allocator; + const params = try QueryParams.parse(allocator, "format=3&lang=de&m"); + defer if (params.format) |f| allocator.free(f); + defer if (params.lang) |l| allocator.free(l); + try std.testing.expectEqualStrings("3", params.format.?); + try std.testing.expectEqualStrings("de", params.lang.?); + try std.testing.expectEqual(QueryParams.Units.metric, params.units.?); +} + +test "parse transparency" { + const allocator = std.testing.allocator; + const params_t = try QueryParams.parse(allocator, "t"); + try std.testing.expectEqual(@as(u8, 150), params_t.transparency.?); + + const params_custom = try QueryParams.parse(allocator, "transparency=200"); + try std.testing.expectEqual(@as(u8, 200), params_custom.transparency.?); +} diff --git a/zig/src/location/resolver.zig b/zig/src/location/resolver.zig index 6eb2745..1a165cc 100644 --- a/zig/src/location/resolver.zig +++ b/zig/src/location/resolver.zig @@ -74,7 +74,7 @@ pub const Resolver = struct { } // Format IP address - const ip_str = try std.fmt.allocPrint(self.allocator, "{}", .{addr_list.addrs[0].any}); + const ip_str = try std.fmt.allocPrint(self.allocator, "{any}", .{addr_list.addrs[0].any}); defer self.allocator.free(ip_str); return self.resolveIP(ip_str); diff --git a/zig/src/main.zig b/zig/src/main.zig index e326766..05ca118 100644 --- a/zig/src/main.zig +++ b/zig/src/main.zig @@ -70,6 +70,9 @@ test { _ = @import("http/query.zig"); _ = @import("http/help.zig"); _ = @import("render/line.zig"); + _ = @import("render/json.zig"); + _ = @import("render/v2.zig"); + _ = @import("render/custom.zig"); _ = @import("location/geoip.zig"); _ = @import("location/resolver.zig"); } diff --git a/zig/src/render/custom.zig b/zig/src/render/custom.zig new file mode 100644 index 0000000..b591d8f --- /dev/null +++ b/zig/src/render/custom.zig @@ -0,0 +1,137 @@ +const std = @import("std"); +const types = @import("../weather/types.zig"); + +const weather_icons = [_][]const u8{ + "✨", // 0-99 + "🌑", // 100-199 + "⛅", // 200-299 + "☁️", // 300-399 +}; + +fn getWeatherIcon(code: u16) []const u8 { + const idx = @min(code / 100, weather_icons.len - 1); + return weather_icons[idx]; +} + +pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format: []const u8) ![]const u8 { + var output: std.ArrayList(u8) = .empty; + errdefer output.deinit(allocator); + const writer = output.writer(allocator); + + var i: usize = 0; + while (i < format.len) { + if (format[i] == '%' and i + 1 < format.len) { + const code = format[i + 1]; + switch (code) { + 'c' => try writer.print("{s}", .{getWeatherIcon(weather.current.weather_code)}), + 'C' => try writer.print("{s}", .{weather.current.condition}), + 'h' => try writer.print("{d}%", .{weather.current.humidity}), + 't' => try writer.print("{d:.0}°C", .{weather.current.temp_c}), + 'f' => try writer.print("{d:.0}°C", .{weather.current.temp_c}), // Feels like (same as temp for now) + 'w' => try writer.print("{d:.0} km/h {s}", .{ weather.current.wind_kph, weather.current.wind_dir }), + 'l' => try writer.print("{s}", .{weather.location}), + 'p' => try writer.print("{d:.1} mm", .{weather.current.precip_mm}), + 'P' => try writer.print("{d:.0} hPa", .{weather.current.pressure_mb}), + 'm' => try writer.print("🌕", .{}), // Moon phase placeholder + 'M' => try writer.print("15", .{}), // Moon day placeholder + 'o' => try writer.print("0%", .{}), // Probability of precipitation placeholder + 'D' => try writer.print("06:00", .{}), // Dawn placeholder + 'S' => try writer.print("07:30", .{}), // Sunrise placeholder + 'z' => try writer.print("12:00", .{}), // Zenith placeholder + 's' => try writer.print("18:30", .{}), // Sunset placeholder + 'd' => try writer.print("20:00", .{}), // Dusk placeholder + '%' => try writer.print("%", .{}), + 'n' => try writer.print("\n", .{}), + else => { + try writer.print("%{c}", .{code}); + }, + } + i += 2; + } else { + try writer.writeByte(format[i]); + i += 1; + } + } + + return output.toOwnedSlice(allocator); +} + +test "render custom format with location and temp" { + const allocator = std.testing.allocator; + + const weather = types.WeatherData{ + .location = "London", + .current = .{ + .temp_c = 7.0, + .temp_f = 44.6, + .condition = "Overcast", + .weather_code = 122, + .humidity = 76, + .wind_kph = 11.0, + .wind_dir = "NNE", + .pressure_mb = 1019.0, + .precip_mm = 0.0, + }, + .forecast = &[_]types.ForecastDay{}, + .allocator = allocator, + }; + + const output = try render(allocator, weather, "%l: %c %t"); + defer allocator.free(output); + + try std.testing.expect(std.mem.indexOf(u8, output, "London") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "7°C") != null); +} + +test "render custom format with newline" { + const allocator = std.testing.allocator; + + const weather = types.WeatherData{ + .location = "Paris", + .current = .{ + .temp_c = 10.0, + .temp_f = 50.0, + .condition = "Clear", + .weather_code = 113, + .humidity = 65, + .wind_kph = 8.0, + .wind_dir = "E", + .pressure_mb = 1020.0, + .precip_mm = 0.0, + }, + .forecast = &[_]types.ForecastDay{}, + .allocator = allocator, + }; + + const output = try render(allocator, weather, "%l%n%C"); + defer allocator.free(output); + + try std.testing.expect(std.mem.indexOf(u8, output, "Paris\nClear") != null); +} + +test "render custom format with humidity and pressure" { + const allocator = std.testing.allocator; + + const weather = types.WeatherData{ + .location = "Berlin", + .current = .{ + .temp_c = 5.0, + .temp_f = 41.0, + .condition = "Cloudy", + .weather_code = 119, + .humidity = 85, + .wind_kph = 12.0, + .wind_dir = "W", + .pressure_mb = 1012.0, + .precip_mm = 0.2, + }, + .forecast = &[_]types.ForecastDay{}, + .allocator = allocator, + }; + + const output = try render(allocator, weather, "Humidity: %h, Pressure: %P"); + defer allocator.free(output); + + try std.testing.expect(std.mem.indexOf(u8, output, "85%") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "1012") != null); +} diff --git a/zig/src/render/json.zig b/zig/src/render/json.zig index 163be80..23ef0d9 100644 --- a/zig/src/render/json.zig +++ b/zig/src/render/json.zig @@ -2,10 +2,7 @@ const std = @import("std"); const types = @import("../weather/types.zig"); pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const u8 { - var output = std.ArrayList(u8).init(allocator); - errdefer output.deinit(); - - try std.json.stringify(.{ + const data = .{ .current_condition = .{ .temp_C = weather.current.temp_c, .temp_F = weather.current.temp_f, @@ -17,31 +14,10 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const .pressure = weather.current.pressure_mb, .precipMM = weather.current.precip_mm, }, - .weather = blk: { - var forecast_array = std.ArrayList(struct { - date: []const u8, - maxtempC: f32, - mintempC: f32, - weatherCode: u16, - weatherDesc: []const u8, - }).init(allocator); - defer forecast_array.deinit(); + .weather = weather.forecast, + }; - for (weather.forecast) |day| { - try forecast_array.append(.{ - .date = day.date, - .maxtempC = day.max_temp_c, - .mintempC = day.min_temp_c, - .weatherCode = day.weather_code, - .weatherDesc = day.condition, - }); - } - - break :blk try forecast_array.toOwnedSlice(); - }, - }, .{}, output.writer()); - - return output.toOwnedSlice(); + return try std.fmt.allocPrint(allocator, "{any}", .{std.json.fmt(data, .{})}); } test "render json format" { diff --git a/zig/src/render/line.zig b/zig/src/render/line.zig index c12a170..10aa141 100644 --- a/zig/src/render/line.zig +++ b/zig/src/render/line.zig @@ -9,30 +9,36 @@ pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, format: []c data.current.temp_c, }); } else if (std.mem.eql(u8, format, "2")) { - return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C 🌬️{s}{d:.0}km/h", .{ + return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C {s}{s}{d:.0}km/h", .{ data.location, getConditionEmoji(data.current.weather_code), data.current.temp_c, + "🌬️", data.current.wind_dir, data.current.wind_kph, }); } else if (std.mem.eql(u8, format, "3")) { - return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C 🌬️{s}{d:.0}km/h 💧{d}%%", .{ + return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C {s}{s}{d:.0}km/h {s}{d}%%", .{ data.location, getConditionEmoji(data.current.weather_code), data.current.temp_c, + "🌬️", data.current.wind_dir, data.current.wind_kph, + "💧", data.current.humidity, }); } else if (std.mem.eql(u8, format, "4")) { - return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C 🌬️{s}{d:.0}km/h 💧{d}%% ☀️", .{ + return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C {s}{s}{d:.0}km/h {s}{d}%% {s}", .{ data.location, getConditionEmoji(data.current.weather_code), data.current.temp_c, + "🌬️", data.current.wind_dir, data.current.wind_kph, + "💧", data.current.humidity, + "☀️", }); } else { return renderCustom(allocator, data, format); diff --git a/zig/src/render/v2.zig b/zig/src/render/v2.zig new file mode 100644 index 0000000..98216ca --- /dev/null +++ b/zig/src/render/v2.zig @@ -0,0 +1,68 @@ +const std = @import("std"); +const types = @import("../weather/types.zig"); + +pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const u8 { + var output: std.ArrayList(u8) = .empty; + errdefer output.deinit(allocator); + const writer = output.writer(allocator); + + // Header with location + try writer.print("Weather report: {s}\n\n", .{weather.location}); + + // Current conditions + try writer.print(" Current conditions\n", .{}); + try writer.print(" {s}\n", .{weather.current.condition}); + try writer.writeAll(" 🌡️ "); + try writer.print("{d:.1}°C ({d:.1}°F)\n", .{ weather.current.temp_c, weather.current.temp_f }); + try writer.writeAll(" 💧 "); + try writer.print("{d}%\n", .{weather.current.humidity}); + try writer.writeAll(" 🌬️ "); + try writer.print("{d:.1} km/h {s}\n", .{ weather.current.wind_kph, weather.current.wind_dir }); + try writer.writeAll(" 🔽 "); + try writer.print("{d:.1} hPa\n", .{weather.current.pressure_mb}); + try writer.writeAll(" 💦 "); + try writer.print("{d:.1} mm\n\n", .{weather.current.precip_mm}); + + // Forecast + if (weather.forecast.len > 0) { + try writer.print(" Forecast\n", .{}); + for (weather.forecast) |day| { + try writer.print(" {s}: {s}\n", .{ day.date, day.condition }); + try writer.writeAll(" ↑ "); + try writer.print("{d:.1}°C ", .{day.max_temp_c}); + try writer.writeAll("↓ "); + try writer.print("{d:.1}°C\n", .{day.min_temp_c}); + } + } + + return output.toOwnedSlice(allocator); +} + +test "render v2 format" { + const allocator = std.testing.allocator; + + const weather = types.WeatherData{ + .location = "Munich", + .current = .{ + .temp_c = 12.0, + .temp_f = 53.6, + .condition = "Overcast", + .weather_code = 122, + .humidity = 80, + .wind_kph = 15.0, + .wind_dir = "NW", + .pressure_mb = 1015.0, + .precip_mm = 0.5, + }, + .forecast = &[_]types.ForecastDay{}, + .allocator = allocator, + }; + + const output = try render(allocator, weather); + defer allocator.free(output); + + try std.testing.expect(output.len > 0); + try std.testing.expect(std.mem.indexOf(u8, output, "Munich") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Current conditions") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "12.0°C") != null); +}