mirror of
https://github.com/Xevion/time-banner.git
synced 2025-12-05 23:16:35 -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)) => {
|
Some((abbreviation, offset)) => {
|
||||||
builder.entry(abbreviation.clone(), offset.to_string());
|
builder.entry(abbreviation.clone(), offset.to_string());
|
||||||
processed_count += 1;
|
processed_count += 1;
|
||||||
// println!(
|
|
||||||
// "cargo:warning=Processed timezone: {} -> {} seconds",
|
|
||||||
// abbreviation, offset
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
skipped_count += 1;
|
skipped_count += 1;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use chrono::Duration;
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
|
use crate::error::TimeBannerError;
|
||||||
|
|
||||||
pub trait Months {
|
pub trait Months {
|
||||||
fn months(count: i32) -> Self;
|
fn months(count: i32) -> Self;
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,7 @@ pub fn parse_duration(str: &str) -> Result<Duration, String> {
|
|||||||
Ok(year) => {
|
Ok(year) => {
|
||||||
Duration::days(year * 365)
|
Duration::days(year * 365)
|
||||||
+ (if year > 0 {
|
+ (if year > 0 {
|
||||||
Duration::hours(6) * year as i32
|
Duration::hours(6) * year as i32 // Leap year compensation
|
||||||
} else {
|
} else {
|
||||||
Duration::zero()
|
Duration::zero()
|
||||||
})
|
})
|
||||||
@@ -141,6 +143,43 @@ pub fn parse_duration(str: &str) -> Result<Duration, String> {
|
|||||||
Ok(value)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::duration::{parse_duration, Months};
|
use crate::duration::{parse_duration, Months};
|
||||||
|
|||||||
50
src/error.rs
50
src/error.rs
@@ -1,7 +1,7 @@
|
|||||||
use axum::http::StatusCode;
|
use axum::{http::StatusCode, response::Json};
|
||||||
use axum::Json;
|
use serde::Serialize;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum TimeBannerError {
|
pub enum TimeBannerError {
|
||||||
ParseError(String),
|
ParseError(String),
|
||||||
RenderError(String),
|
RenderError(String),
|
||||||
@@ -9,33 +9,41 @@ pub enum TimeBannerError {
|
|||||||
NotFound,
|
NotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
code: u16,
|
error: String,
|
||||||
message: String,
|
message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_error_response(error: TimeBannerError) -> (StatusCode, Json<ErrorResponse>) {
|
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) => (
|
TimeBannerError::RenderError(msg) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
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) => (
|
TimeBannerError::RasterizeError(msg) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("RasterizeError :: {}", msg),
|
Json(ErrorResponse {
|
||||||
|
error: "RasterizeError".to_string(),
|
||||||
|
message: msg,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
TimeBannerError::NotFound => (StatusCode::NOT_FOUND, "Not Found".to_string()),
|
TimeBannerError::NotFound => (
|
||||||
};
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
(
|
error: "NotFound".to_string(),
|
||||||
code,
|
message: "The requested resource was not found".to_string(),
|
||||||
Json(ErrorResponse {
|
}),
|
||||||
code: code.as_u16(),
|
),
|
||||||
message,
|
}
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ mod raster;
|
|||||||
mod render;
|
mod render;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod template;
|
mod template;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ impl Rasterizer {
|
|||||||
tree_result.unwrap()
|
tree_result.unwrap()
|
||||||
};
|
};
|
||||||
|
|
||||||
let zoom = 0.90;
|
|
||||||
let pixmap_size = tree.size().to_int_size();
|
let pixmap_size = tree.size().to_int_size();
|
||||||
let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height()).unwrap();
|
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;
|
let center_y = pixmap_size.height() as f32 / 2.0;
|
||||||
|
|
||||||
// Create transform that scales from center: translate to center, scale, translate back
|
// 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)
|
let render_ts = tiny_skia::Transform::from_translate(-center_x, -center_y)
|
||||||
.post_scale(zoom, zoom)
|
.post_scale(zoom, zoom)
|
||||||
.post_translate(center_x, center_y);
|
.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 {
|
pub fn mime_type(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
OutputFormat::Svg => "image/svg+xml",
|
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::error::{get_error_response, TimeBannerError};
|
||||||
use crate::render::render_time_response;
|
use crate::render::render_time_response;
|
||||||
use crate::template::OutputForm;
|
use crate::template::OutputForm;
|
||||||
|
use crate::utils::parse_path;
|
||||||
use axum::extract::Path;
|
use axum::extract::Path;
|
||||||
use axum::response::IntoResponse;
|
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 {
|
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()
|
axum::response::Redirect::temporary(&format!("/relative/{epoch_now}")).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ lazy_static! {
|
|||||||
"templates/**/*.svg"
|
"templates/**/*.svg"
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut _tera = match Tera::new(template_pattern) {
|
match Tera::new(template_pattern) {
|
||||||
Ok(t) => {
|
Ok(t) => {
|
||||||
let names: Vec<&str> = t.get_template_names().collect();
|
let names: Vec<&str> = t.get_template_names().collect();
|
||||||
println!("{} templates found ([{}]).", names.len(), names.join(", "));
|
println!("{} templates found ([{}]).", names.len(), names.join(", "));
|
||||||
@@ -25,9 +25,7 @@ lazy_static! {
|
|||||||
println!("Parsing error(s): {}", e);
|
println!("Parsing error(s): {}", e);
|
||||||
::std::process::exit(1);
|
::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