From d341004f59916c2a6311a0d77d14ad84129d80b3 Mon Sep 17 00:00:00 2001 From: sergio Date: Tue, 19 May 2026 00:55:51 +0000 Subject: [PATCH] feat(cosmobiologia-server): server HTTP single-user con CRUD completo (fase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crate nuevo `cosmobiologia-server` (binario axum, nativo) que monta `cosmobiologia-engine` + `cosmobiologia-store` y expone la rueda + el CRUD del tree por HTTP. Endpoints v1: - `GET /api/health` - `GET /api/tree` tree completo anidado - `POST /api/groups` crear grupo - `PATCH /api/groups/:id` renombrar - `DELETE /api/groups/:id` borrar - `POST /api/contacts` crear contacto - `PATCH /api/contacts/:id` renombrar - `DELETE /api/contacts/:id` borrar - `POST /api/charts` crear carta - `GET /api/charts/:id` chart JSON - `PATCH /api/charts/:id` editar (label/birth/config) - `DELETE /api/charts/:id` borrar - `GET /api/charts/:id/render` RenderModel JSON - `GET /api/charts/:id/svg` SVG inline (reusa svg_export del engine) - `GET /api/sky` "Cielo ahora" — RenderModel UTC actual sin chart_id real Query params del render para activar overlays sin POST: - `offset_min=` time scrubbing - `transit=1` overlay de tránsito al now - `prog_age=` progresión secundaria - `sa_age=` solar arc - `pd_age=` primary directions (Naibod) Decisiones: - Single-user, sin auth. Bind por default a `127.0.0.1:8787` — el server NO debe exponerse a la red pública en esta fase. - DB por default = misma del desktop (`$XDG_DATA_HOME/cosmobiologia/ charts.db`). `--db` permite override. - CORS permissive (es localhost, single-user, sin auth). - `ApiError` con mapeo a HTTP status: 404 NotFound, 400 BadRequest, 500 todo lo demás. Body JSON `{ "error": "..." }`. Smoke test: cargo run -p cosmobiologia-server -- --port 18787 curl /api/health → {"status":"ok",...} curl POST /api/groups → {"id":"01KRYVP...","name":"Familia",...} curl POST /api/contacts → {"id":"01KRYVP...","group_id":...} curl /api/tree → árbol anidado curl /api/sky → RenderModel con VSOP real Pendiente (fase 3): cliente `cosmobiologia-web` (cdylib WASM) que consuma estos endpoints y pinte SVG/Canvas2D. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 117 ++++ Cargo.toml | 1 + crates/apps/cosmobiologia-server/Cargo.toml | 27 + crates/apps/cosmobiologia-server/src/main.rs | 536 +++++++++++++++++++ 4 files changed, 681 insertions(+) create mode 100644 crates/apps/cosmobiologia-server/Cargo.toml create mode 100644 crates/apps/cosmobiologia-server/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index b57f55e..8cda503 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -870,6 +870,61 @@ dependencies = [ "arrayvec 0.7.6", ] +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -2248,6 +2303,26 @@ dependencies = [ "serde", ] +[[package]] +name = "cosmobiologia-server" +version = "0.1.0" +dependencies = [ + "axum", + "clap", + "cosmobiologia-engine", + "cosmobiologia-model", + "cosmobiologia-render", + "cosmobiologia-store", + "directories", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + [[package]] name = "cosmobiologia-store" version = "0.1.0" @@ -4917,12 +4992,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.3.0" @@ -4943,6 +5030,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -6571,6 +6659,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matrixmultiply" version = "0.3.10" @@ -9955,6 +10049,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -11551,6 +11656,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -11561,13 +11667,23 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags 2.11.1", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", "url", ] @@ -11589,6 +11705,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index 8a1ba03..8f97c75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,6 +172,7 @@ members = [ "crates/apps/lapaloma-financial-demo", "crates/apps/cosmobiologia", "crates/apps/cosmobiologia-cli", + "crates/apps/cosmobiologia-server", ] [workspace.package] diff --git a/crates/apps/cosmobiologia-server/Cargo.toml b/crates/apps/cosmobiologia-server/Cargo.toml new file mode 100644 index 0000000..ba4d327 --- /dev/null +++ b/crates/apps/cosmobiologia-server/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "cosmobiologia-server" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Cosmobiología — server HTTP single-user. axum + cosmobiologia-engine. Sirve cartas y assets del cliente web. CRUD completo de groups/contacts/charts." + +[dependencies] +cosmobiologia-engine = { path = "../../modules/cosmobiologia/cosmobiologia-engine" } +cosmobiologia-model = { path = "../../modules/cosmobiologia/cosmobiologia-model" } +cosmobiologia-render = { path = "../../modules/cosmobiologia/cosmobiologia-render" } +cosmobiologia-store = { path = "../../modules/cosmobiologia/cosmobiologia-store" } + +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +directories = { workspace = true } +clap = { version = "4", features = ["derive"] } +axum = "0.7" +tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } + +[[bin]] +name = "cosmobiologia-server" +path = "src/main.rs" diff --git a/crates/apps/cosmobiologia-server/src/main.rs b/crates/apps/cosmobiologia-server/src/main.rs new file mode 100644 index 0000000..1c4933c --- /dev/null +++ b/crates/apps/cosmobiologia-server/src/main.rs @@ -0,0 +1,536 @@ +//! Cosmobiología — server HTTP single-user. +//! +//! - Reusa `cosmobiologia-engine` (VSOP2013 + LRU cache) nativo. +//! - Comparte (por default) la misma `charts.db` SQLite que la app +//! desktop, vía `directories::ProjectDirs::from("net", "gioser", +//! "cosmobiologia")`. La idea es: levantar `cosmobiologia-server` +//! en localhost y abrir el wheel desde el browser cuando no se está +//! con la app desktop. +//! - Single-user, sin auth, bind a `127.0.0.1` por default. NO debe +//! exponerse a la red pública sin agregar auth + HTTPS. +//! +//! ## Endpoints (v1) +//! +//! ```text +//! GET /api/health healthcheck +//! GET /api/tree tree completo (groups + contacts + charts) +//! POST /api/groups crear grupo +//! PATCH /api/groups/:id renombrar +//! DELETE /api/groups/:id borrar +//! POST /api/contacts crear contacto +//! PATCH /api/contacts/:id renombrar +//! DELETE /api/contacts/:id borrar +//! POST /api/charts crear carta (contact_id + birth_data) +//! GET /api/charts/:id chart JSON +//! PATCH /api/charts/:id renombrar / editar birth_data +//! DELETE /api/charts/:id borrar +//! GET /api/charts/:id/render RenderModel JSON (overlays via query) +//! GET /api/charts/:id/svg SVG inline +//! GET /api/sky "Cielo ahora" — RenderModel UTC actual +//! ``` + +#![forbid(unsafe_code)] +#![warn(rust_2018_idioms)] + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, patch, post}; +use axum::{Json, Router}; +use clap::Parser; +use cosmobiologia_engine::{ + compose_with_options, svg_export, EngineError, NatalOptions, PipelineRequest, RenderModel, +}; +use cosmobiologia_model::{ + Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, StoredBirthData, + StoredChartConfig, +}; +use cosmobiologia_store::Store; +use serde::{Deserialize, Serialize}; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; +use tracing::info; + +#[derive(Parser, Debug)] +#[command( + name = "cosmobiologia-server", + about = "Servidor HTTP single-user de Cosmobiología." +)] +struct Cli { + /// Puerto donde escuchar. Default 8787. + #[arg(long, default_value = "8787")] + port: u16, + /// IP a bindear. Default `127.0.0.1` (solo localhost — single-user + /// sin auth). + #[arg(long, default_value = "127.0.0.1")] + bind: String, + /// Path al archivo SQLite. Default = el mismo de la app desktop + /// (`$XDG_DATA_HOME/cosmobiologia/charts.db`). + #[arg(long)] + db: Option, +} + +#[derive(Clone)] +struct AppState { + store: Arc, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "cosmobiologia_server=info,tower_http=info".into()), + ) + .init(); + + let cli = Cli::parse(); + + let db_path = match cli.db { + Some(p) => p, + None => default_db_path()?, + }; + info!("DB: {}", db_path.display()); + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + let store = Arc::new(Store::open(&db_path)?); + + let state = AppState { store }; + let app = router().with_state(state); + + let addr: SocketAddr = format!("{}:{}", cli.bind, cli.port).parse()?; + info!("listening on http://{}", addr); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +fn default_db_path() -> Result> { + let dirs = directories::ProjectDirs::from("net", "gioser", "cosmobiologia") + .ok_or("no se pudo determinar XDG data dir")?; + Ok(dirs.data_dir().join("charts.db")) +} + +fn router() -> Router { + Router::new() + .route("/api/health", get(health)) + .route("/api/tree", get(get_tree)) + .route("/api/sky", get(get_sky)) + .route("/api/groups", post(post_group)) + .route("/api/groups/:id", patch(patch_group).delete(delete_group)) + .route("/api/contacts", post(post_contact)) + .route( + "/api/contacts/:id", + patch(patch_contact).delete(delete_contact), + ) + .route("/api/charts", post(post_chart)) + .route( + "/api/charts/:id", + get(get_chart).patch(patch_chart).delete(delete_chart), + ) + .route("/api/charts/:id/render", get(get_chart_render)) + .route("/api/charts/:id/svg", get(get_chart_svg)) + .layer(CorsLayer::permissive()) // single-user, localhost: cors abierto + .layer(TraceLayer::new_for_http()) +} + +// ===================================================================== +// Error +// ===================================================================== + +#[derive(thiserror::Error, Debug)] +enum ApiError { + #[error("not found: {0}")] + NotFound(String), + #[error("bad request: {0}")] + BadRequest(String), + #[error("store: {0}")] + Store(#[from] cosmobiologia_store::StoreError), + #[error("engine: {0}")] + Engine(#[from] EngineError), +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (code, msg) = match &self { + ApiError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), + ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + }; + (code, Json(serde_json::json!({ "error": msg }))).into_response() + } +} + +type ApiResult = Result, ApiError>; + +// ===================================================================== +// Health +// ===================================================================== + +async fn health() -> Json { + Json(serde_json::json!({ "status": "ok", "service": "cosmobiologia-server" })) +} + +// ===================================================================== +// Tree — listado completo +// ===================================================================== + +#[derive(Serialize)] +struct TreeNode { + id: String, + label: String, + kind: &'static str, // "group" | "contact" | "chart" + children: Vec, +} + +async fn get_tree(State(s): State) -> ApiResult> { + let mut roots = Vec::new(); + // Grupos top-level + for g in s.store.list_groups(None)? { + roots.push(group_node(&s.store, &g)?); + } + // Contactos sin grupo (van bajo "General" en el tree desktop; + // acá los listamos directo al root para no confundir al cliente). + for c in s.store.list_contacts(None)? { + roots.push(contact_node(&s.store, &c)?); + } + Ok(Json(roots)) +} + +fn group_node(store: &Store, g: &Group) -> Result { + let mut children = Vec::new(); + for sub in store.list_groups(Some(g.id))? { + children.push(group_node(store, &sub)?); + } + for c in store.list_contacts(Some(g.id))? { + children.push(contact_node(store, &c)?); + } + Ok(TreeNode { + id: format!("g:{}", g.id), + label: g.name.clone(), + kind: "group", + children, + }) +} + +fn contact_node(store: &Store, c: &Contact) -> Result { + let charts = store.list_charts(c.id).unwrap_or_default(); + let children: Vec = charts + .into_iter() + .map(|h| TreeNode { + id: format!("h:{}", h.id), + label: h.label, + kind: "chart", + children: Vec::new(), + }) + .collect(); + Ok(TreeNode { + id: format!("c:{}", c.id), + label: c.name.clone(), + kind: "contact", + children, + }) +} + +// ===================================================================== +// Groups CRUD +// ===================================================================== + +#[derive(Deserialize)] +struct CreateGroupBody { + name: String, + parent: Option, +} + +async fn post_group( + State(s): State, + Json(b): Json, +) -> ApiResult { + let g = s.store.create_group(b.parent, &b.name, None)?; + Ok(Json(g)) +} + +#[derive(Deserialize)] +struct PatchGroupBody { + name: String, +} + +async fn patch_group( + State(s): State, + Path(id): Path, + Json(b): Json, +) -> ApiResult { + s.store.rename_group(id, &b.name)?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +async fn delete_group( + State(s): State, + Path(id): Path, +) -> ApiResult { + s.store.delete_group(id)?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// ===================================================================== +// Contacts CRUD +// ===================================================================== + +#[derive(Deserialize)] +struct CreateContactBody { + name: String, + group: Option, +} + +async fn post_contact( + State(s): State, + Json(b): Json, +) -> ApiResult { + let c = s.store.create_contact(b.group, &b.name, None)?; + Ok(Json(c)) +} + +#[derive(Deserialize)] +struct PatchContactBody { + name: String, +} + +async fn patch_contact( + State(s): State, + Path(id): Path, + Json(b): Json, +) -> ApiResult { + s.store.rename_contact(id, &b.name)?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +async fn delete_contact( + State(s): State, + Path(id): Path, +) -> ApiResult { + s.store.delete_contact(id)?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// ===================================================================== +// Charts CRUD +// ===================================================================== + +#[derive(Deserialize)] +struct CreateChartBody { + contact_id: ContactId, + #[serde(default)] + kind: Option, + label: String, + birth_data: StoredBirthData, + #[serde(default)] + config: Option, +} + +async fn post_chart( + State(s): State, + Json(b): Json, +) -> ApiResult { + let kind = b.kind.unwrap_or(ChartKind::Natal); + let cfg = b.config.unwrap_or_default(); + let chart = s + .store + .create_chart(b.contact_id, kind, &b.label, &b.birth_data, &cfg, None)?; + Ok(Json(chart)) +} + +async fn get_chart( + State(s): State, + Path(id): Path, +) -> ApiResult { + let chart = s + .store + .get_chart(id) + .map_err(|_| ApiError::NotFound(format!("chart {}", id)))?; + Ok(Json(chart)) +} + +#[derive(Deserialize)] +struct PatchChartBody { + #[serde(default)] + label: Option, + #[serde(default)] + birth_data: Option, + #[serde(default)] + config: Option, +} + +async fn patch_chart( + State(s): State, + Path(id): Path, + Json(b): Json, +) -> ApiResult { + let current = s + .store + .get_chart(id) + .map_err(|_| ApiError::NotFound(format!("chart {}", id)))?; + let label = b.label.unwrap_or(current.label); + let birth = b.birth_data.unwrap_or(current.birth_data); + let cfg = b.config.unwrap_or(current.config); + s.store.update_chart(id, &label, &birth, &cfg)?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +async fn delete_chart( + State(s): State, + Path(id): Path, +) -> ApiResult { + s.store.delete_chart(id)?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +// ===================================================================== +// Render +// ===================================================================== + +#[derive(Deserialize, Default)] +struct RenderQuery { + /// Offset de tiempo en minutos (para "scrubbing"). + #[serde(default)] + offset_min: i64, + /// "1" = activar overlay de tránsitos al `now` del server. + #[serde(default)] + transit: u8, + /// Edad (años) — activa progresión secundaria si se setea. + #[serde(default)] + prog_age: Option, + /// Edad (años) — activa solar arc si se setea. + #[serde(default)] + sa_age: Option, + /// Edad (años) — activa primary directions si se setea. + #[serde(default)] + pd_age: Option, +} + +fn build_requests(q: &RenderQuery) -> Vec { + let mut r = Vec::new(); + if q.transit == 1 { + r.push(PipelineRequest::Transit); + } + if let Some(a) = q.prog_age { + r.push(PipelineRequest::SecondaryProgression { target_age_years: a }); + } + if let Some(a) = q.sa_age { + r.push(PipelineRequest::SolarArc { target_age_years: a }); + } + if let Some(a) = q.pd_age { + r.push(PipelineRequest::PrimaryDirections { + target_age_years: a, + key: "naibod".into(), + }); + } + r +} + +async fn get_chart_render( + State(s): State, + Path(id): Path, + Query(q): Query, +) -> ApiResult { + let chart = s + .store + .get_chart(id) + .map_err(|_| ApiError::NotFound(format!("chart {}", id)))?; + let model = + compose_with_options(&chart, q.offset_min, &build_requests(&q), &NatalOptions::default())?; + Ok(Json(model)) +} + +async fn get_chart_svg( + State(s): State, + Path(id): Path, + Query(q): Query, +) -> Result { + let chart = s + .store + .get_chart(id) + .map_err(|_| ApiError::NotFound(format!("chart {}", id)))?; + let model = + compose_with_options(&chart, q.offset_min, &build_requests(&q), &NatalOptions::default())?; + let svg = svg_export::render_to_svg(&model); + Ok(( + [(axum::http::header::CONTENT_TYPE, "image/svg+xml")], + svg, + ) + .into_response()) +} + +// ===================================================================== +// Sky now — sin chart +// ===================================================================== + +async fn get_sky() -> ApiResult { + let chart = build_present_sky_chart(); + let model = compose_with_options(&chart, 0, &[], &NatalOptions::default())?; + Ok(Json(model)) +} + +fn build_present_sky_chart() -> Chart { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let (year, month, day, hour, minute, second) = unix_to_civil_utc(secs); + let birth = StoredBirthData { + year, + month, + day, + hour, + minute, + second: second as f64, + tz_offset_minutes: 0, + latitude_deg: 51.4769, // Greenwich + longitude_deg: 0.0, + altitude_m: 47.0, + time_certainty: Default::default(), + subject_name: Some("Cielo".into()), + birthplace_label: Some("Greenwich (UTC)".into()), + }; + Chart { + id: ChartId::default(), + contact_id: ContactId::default(), + kind: ChartKind::Natal, + label: format!( + "Cielo {:04}-{:02}-{:02} {:02}:{:02} UTC", + year, month, day, hour, minute + ), + birth_data: birth, + config: StoredChartConfig::default(), + related_chart_id: None, + created_at_ms: 0, + } +} + +/// Howard Hinnant `days_to_civil` — Unix UTC → calendario. +/// Mismo algoritmo que en la app desktop; duplicado mínimo para no +/// arrastrar el shell entero como dep del server. +fn unix_to_civil_utc(secs: i64) -> (i32, u32, u32, u32, u32, u32) { + let day_seconds: i64 = 86_400; + let z = secs.div_euclid(day_seconds); + let s = secs.rem_euclid(day_seconds); + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u32; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if month <= 2 { (y + 1) as i32 } else { y as i32 }; + let hour = (s / 3600) as u32; + let minute = ((s % 3600) / 60) as u32; + let second = (s % 60) as u32; + (year, month, day, hour, minute, second) +}