mirror of
https://github.com/Xevion/time-banner.git
synced 2025-12-05 21:16:33 -06:00
feat: enhance duration parsing and error handling, add utility functions
This commit is contained in:
4
build.rs
4
build.rs
@@ -152,10 +152,6 @@ fn generate_timezone_map() -> Result<(), BuildError> {
|
||||
Some((abbreviation, offset)) => {
|
||||
builder.entry(abbreviation.clone(), offset.to_string());
|
||||
processed_count += 1;
|
||||
// println!(
|
||||
// "cargo:warning=Processed timezone: {} -> {} seconds",
|
||||
// abbreviation, offset
|
||||
// );
|
||||
}
|
||||
None => {
|
||||
skipped_count += 1;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use chrono::Duration;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::error::TimeBannerError;
|
||||
|
||||
pub trait Months {
|
||||
fn months(count: i32) -> Self;
|
||||
}
|
||||
@@ -37,7 +39,7 @@ pub fn parse_duration(str: &str) -> Result<Duration, String> {
|
||||
Ok(year) => {
|
||||
Duration::days(year * 365)
|
||||
+ (if year > 0 {
|
||||
Duration::hours(6) * year as i32
|
||||
Duration::hours(6) * year as i32 // Leap year compensation
|
||||
} else {
|
||||
Duration::zero()
|
||||
})
|
||||
@@ -141,6 +143,43 @@ pub fn parse_duration(str: &str) -> Result<Duration, String> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn parse_epoch_into_datetime(epoch: i64) -> Option<DateTime<Utc>> {
|
||||
DateTime::from_timestamp(epoch, 0)
|
||||
}
|
||||
|
||||
pub fn parse_time_value(raw_time: &str) -> Result<DateTime<Utc>, TimeBannerError> {
|
||||
// Handle relative time values (starting with + or -, or duration strings like "1y2d")
|
||||
if raw_time.starts_with('+') || raw_time.starts_with('-') {
|
||||
let now = Utc::now();
|
||||
|
||||
// Try parsing as simple offset seconds first
|
||||
if let Ok(offset_seconds) = raw_time.parse::<i64>() {
|
||||
return Ok(now + Duration::seconds(offset_seconds));
|
||||
}
|
||||
|
||||
// Try parsing as duration string (e.g., "+1y2d", "-3h30m")
|
||||
if let Ok(duration) = parse_duration(raw_time) {
|
||||
return Ok(now + duration);
|
||||
}
|
||||
|
||||
return Err(TimeBannerError::ParseError(format!(
|
||||
"Could not parse relative time: {}",
|
||||
raw_time
|
||||
)));
|
||||
}
|
||||
|
||||
// Try to parse as epoch timestamp
|
||||
if let Ok(epoch) = raw_time.parse::<i64>() {
|
||||
return parse_epoch_into_datetime(epoch)
|
||||
.ok_or_else(|| TimeBannerError::ParseError("Invalid timestamp".to_string()));
|
||||
}
|
||||
|
||||
Err(TimeBannerError::ParseError(format!(
|
||||
"Could not parse time value: {}",
|
||||
raw_time
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::duration::{parse_duration, Months};
|
||||
|
||||
50
src/error.rs
50
src/error.rs
@@ -1,7 +1,7 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use axum::{http::StatusCode, response::Json};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TimeBannerError {
|
||||
ParseError(String),
|
||||
RenderError(String),
|
||||
@@ -9,33 +9,41 @@ pub enum TimeBannerError {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
code: u16,
|
||||
error: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub fn get_error_response(error: TimeBannerError) -> (StatusCode, Json<ErrorResponse>) {
|
||||
let (code, message) = match error {
|
||||
match error {
|
||||
TimeBannerError::ParseError(msg) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "ParseError".to_string(),
|
||||
message: msg,
|
||||
}),
|
||||
),
|
||||
TimeBannerError::RenderError(msg) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("RenderError :: {}", msg),
|
||||
Json(ErrorResponse {
|
||||
error: "RenderError".to_string(),
|
||||
message: msg,
|
||||
}),
|
||||
),
|
||||
TimeBannerError::ParseError(msg) => {
|
||||
(StatusCode::BAD_REQUEST, format!("ParserError :: {}", msg))
|
||||
}
|
||||
TimeBannerError::RasterizeError(msg) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("RasterizeError :: {}", msg),
|
||||
Json(ErrorResponse {
|
||||
error: "RasterizeError".to_string(),
|
||||
message: msg,
|
||||
}),
|
||||
),
|
||||
TimeBannerError::NotFound => (StatusCode::NOT_FOUND, "Not Found".to_string()),
|
||||
};
|
||||
|
||||
(
|
||||
code,
|
||||
Json(ErrorResponse {
|
||||
code: code.as_u16(),
|
||||
message,
|
||||
}),
|
||||
)
|
||||
TimeBannerError::NotFound => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "NotFound".to_string(),
|
||||
message: "The requested resource was not found".to_string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ mod raster;
|
||||
mod render;
|
||||
mod routes;
|
||||
mod template;
|
||||
mod utils;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
|
||||
@@ -49,7 +49,6 @@ impl Rasterizer {
|
||||
tree_result.unwrap()
|
||||
};
|
||||
|
||||
let zoom = 0.90;
|
||||
let pixmap_size = tree.size().to_int_size();
|
||||
let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height()).unwrap();
|
||||
|
||||
@@ -58,6 +57,7 @@ impl Rasterizer {
|
||||
let center_y = pixmap_size.height() as f32 / 2.0;
|
||||
|
||||
// Create transform that scales from center: translate to center, scale, translate back
|
||||
let zoom = 0.90; // 10% zoom out from center
|
||||
let render_ts = tiny_skia::Transform::from_translate(-center_x, -center_y)
|
||||
.post_scale(zoom, zoom)
|
||||
.post_translate(center_x, center_y);
|
||||
|
||||
@@ -20,6 +20,15 @@ impl OutputFormat {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_mime_type(mime_type: &str) -> Self {
|
||||
// TODO: Support mime types dynamically, proper header parsing
|
||||
match mime_type {
|
||||
"image/svg+xml" => OutputFormat::Svg,
|
||||
"image/png" => OutputFormat::Png,
|
||||
_ => OutputFormat::Svg, // Default to SVG
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mime_type(&self) -> &'static str {
|
||||
match self {
|
||||
OutputFormat::Svg => "image/svg+xml",
|
||||
|
||||
@@ -1,65 +1,13 @@
|
||||
use crate::duration::parse_duration;
|
||||
use crate::duration::parse_time_value;
|
||||
use crate::error::{get_error_response, TimeBannerError};
|
||||
use crate::render::render_time_response;
|
||||
use crate::template::OutputForm;
|
||||
use crate::utils::parse_path;
|
||||
use axum::extract::Path;
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
pub fn split_on_extension(path: &str) -> Option<(&str, &str)> {
|
||||
let split = path.rsplit_once('.')?;
|
||||
|
||||
// Check that the file is not a dotfile (.env)
|
||||
if split.0.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(split)
|
||||
}
|
||||
|
||||
fn parse_path(path: &str) -> (&str, &str) {
|
||||
split_on_extension(path).unwrap_or((path, "svg"))
|
||||
}
|
||||
|
||||
fn parse_epoch_into_datetime(epoch: i64) -> Option<DateTime<Utc>> {
|
||||
DateTime::from_timestamp(epoch, 0)
|
||||
}
|
||||
|
||||
fn parse_time_value(raw_time: &str) -> Result<DateTime<Utc>, TimeBannerError> {
|
||||
// Handle relative time values (starting with + or -, or duration strings like "1y2d")
|
||||
if raw_time.starts_with('+') || raw_time.starts_with('-') {
|
||||
let now = Utc::now();
|
||||
|
||||
// Try parsing as simple offset seconds first
|
||||
if let Ok(offset_seconds) = raw_time.parse::<i64>() {
|
||||
return Ok(now + chrono::Duration::seconds(offset_seconds));
|
||||
}
|
||||
|
||||
// Try parsing as duration string (e.g., "+1y2d", "-3h30m")
|
||||
if let Ok(duration) = parse_duration(raw_time) {
|
||||
return Ok(now + duration);
|
||||
}
|
||||
|
||||
return Err(TimeBannerError::ParseError(format!(
|
||||
"Could not parse relative time: {}",
|
||||
raw_time
|
||||
)));
|
||||
}
|
||||
|
||||
// Try to parse as epoch timestamp
|
||||
if let Ok(epoch) = raw_time.parse::<i64>() {
|
||||
return parse_epoch_into_datetime(epoch)
|
||||
.ok_or_else(|| TimeBannerError::ParseError("Invalid timestamp".to_string()));
|
||||
}
|
||||
|
||||
Err(TimeBannerError::ParseError(format!(
|
||||
"Could not parse time value: {}",
|
||||
raw_time
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn index_handler() -> impl IntoResponse {
|
||||
let epoch_now = Utc::now().timestamp();
|
||||
let epoch_now = chrono::Utc::now().timestamp();
|
||||
|
||||
axum::response::Redirect::temporary(&format!("/relative/{epoch_now}")).into_response()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ lazy_static! {
|
||||
"templates/**/*.svg"
|
||||
};
|
||||
|
||||
let mut _tera = match Tera::new(template_pattern) {
|
||||
match Tera::new(template_pattern) {
|
||||
Ok(t) => {
|
||||
let names: Vec<&str> = t.get_template_names().collect();
|
||||
println!("{} templates found ([{}]).", names.len(), names.join(", "));
|
||||
@@ -25,9 +25,7 @@ lazy_static! {
|
||||
println!("Parsing error(s): {}", e);
|
||||
::std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
_tera
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
49
src/utils.rs
Normal file
49
src/utils.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! General utility functions used across the application.
|
||||
|
||||
/// Splits a path on the last dot to extract filename and extension.
|
||||
/// Returns None for dotfiles (paths starting with a dot).
|
||||
pub fn split_on_extension(path: &str) -> Option<(&str, &str)> {
|
||||
let split = path.rsplit_once('.')?;
|
||||
|
||||
// Check that the file is not a dotfile (.env)
|
||||
if split.0.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(split)
|
||||
}
|
||||
|
||||
/// Parses path into (filename, extension). Defaults to "svg" if no extension found.
|
||||
pub fn parse_path(path: &str) -> (&str, &str) {
|
||||
split_on_extension(path).unwrap_or((path, "svg"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_split_on_extension() {
|
||||
assert_eq!(split_on_extension("file.txt"), Some(("file", "txt")));
|
||||
assert_eq!(
|
||||
split_on_extension("path/to/file.png"),
|
||||
Some(("path/to/file", "png"))
|
||||
);
|
||||
assert_eq!(split_on_extension("noextension"), None);
|
||||
assert_eq!(split_on_extension(".dotfile"), None); // dotfiles return None
|
||||
assert_eq!(split_on_extension("file."), Some(("file", "")));
|
||||
assert_eq!(
|
||||
split_on_extension("file.name.ext"),
|
||||
Some(("file.name", "ext"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_path() {
|
||||
assert_eq!(parse_path("file.txt"), ("file", "txt"));
|
||||
assert_eq!(parse_path("path/to/file.png"), ("path/to/file", "png"));
|
||||
assert_eq!(parse_path("noextension"), ("noextension", "svg")); // default to svg
|
||||
assert_eq!(parse_path(".dotfile"), (".dotfile", "svg")); // dotfiles get svg default
|
||||
assert_eq!(parse_path("file."), ("file", ""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user