feat(cosmobiologia-server): server HTTP single-user con CRUD completo (fase 2)
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=<i64>` time scrubbing
- `transit=1` overlay de tránsito al now
- `prog_age=<f64>` progresión secundaria
- `sa_age=<f64>` solar arc
- `pd_age=<f64>` 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 <noreply@anthropic.com>
d341004f59916c2a6311a0d77d14ad84129d80b3 · por sergio · 2026-05-19T00:55:51Z (hace 5 meses) · padre 9b1295866 · ver el árbol en este commit
Delta semántico · 36
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<PathBuf>,
+}
+
+#[derive(Clone)]
+struct AppState {
+ store: Arc<Store>,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+ 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<PathBuf, Box<dyn std::error::Error>> {
+ 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<AppState> {
+ 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<T> = Result<Json<T>, ApiError>;
+
+// =====================================================================
+// Health
+// =====================================================================
+
+async fn health() -> Json<serde_json::Value> {
+ 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<TreeNode>,
+}
+
+async fn get_tree(State(s): State<AppState>) -> ApiResult<Vec<TreeNode>> {
+ 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<TreeNode, ApiError> {
+ 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<TreeNode, ApiError> {
+ let charts = store.list_charts(c.id).unwrap_or_default();
+ let children: Vec<TreeNode> = 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<GroupId>,
+}
+
+async fn post_group(
+ State(s): State<AppState>,
+ Json(b): Json<CreateGroupBody>,
+) -> ApiResult<Group> {
+ 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<AppState>,
+ Path(id): Path<GroupId>,
+ Json(b): Json<PatchGroupBody>,
+) -> ApiResult<serde_json::Value> {
+ s.store.rename_group(id, &b.name)?;
+ Ok(Json(serde_json::json!({ "ok": true })))
+}
+
+async fn delete_group(
+ State(s): State<AppState>,
+ Path(id): Path<GroupId>,
+) -> ApiResult<serde_json::Value> {
+ s.store.delete_group(id)?;
+ Ok(Json(serde_json::json!({ "ok": true })))
+}
+
+// =====================================================================
+// Contacts CRUD
+// =====================================================================
+
+#[derive(Deserialize)]
+struct CreateContactBody {
+ name: String,
+ group: Option<GroupId>,
+}
+
+async fn post_contact(
+ State(s): State<AppState>,
+ Json(b): Json<CreateContactBody>,
+) -> ApiResult<Contact> {
+ 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<AppState>,
+ Path(id): Path<ContactId>,
+ Json(b): Json<PatchContactBody>,
+) -> ApiResult<serde_json::Value> {
+ s.store.rename_contact(id, &b.name)?;
+ Ok(Json(serde_json::json!({ "ok": true })))
+}
+
+async fn delete_contact(
+ State(s): State<AppState>,
+ Path(id): Path<ContactId>,
+) -> ApiResult<serde_json::Value> {
+ 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<ChartKind>,
+ label: String,
+ birth_data: StoredBirthData,
+ #[serde(default)]
+ config: Option<StoredChartConfig>,
+}
+
+async fn post_chart(
+ State(s): State<AppState>,
+ Json(b): Json<CreateChartBody>,
+) -> ApiResult<Chart> {
+ 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<AppState>,
+ Path(id): Path<ChartId>,
+) -> ApiResult<Chart> {
+ 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<String>,
+ #[serde(default)]
+ birth_data: Option<StoredBirthData>,
+ #[serde(default)]
+ config: Option<StoredChartConfig>,
+}
+
+async fn patch_chart(
+ State(s): State<AppState>,
+ Path(id): Path<ChartId>,
+ Json(b): Json<PatchChartBody>,
+) -> ApiResult<serde_json::Value> {
+ 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<AppState>,
+ Path(id): Path<ChartId>,
+) -> ApiResult<serde_json::Value> {
+ 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<f64>,
+ /// Edad (años) — activa solar arc si se setea.
+ #[serde(default)]
+ sa_age: Option<f64>,
+ /// Edad (años) — activa primary directions si se setea.
+ #[serde(default)]
+ pd_age: Option<f64>,
+}
+
+fn build_requests(q: &RenderQuery) -> Vec<PipelineRequest> {
+ 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<AppState>,
+ Path(id): Path<ChartId>,
+ Query(q): Query<RenderQuery>,
+) -> ApiResult<RenderModel> {
+ 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<AppState>,
+ Path(id): Path<ChartId>,
+ Query(q): Query<RenderQuery>,
+) -> Result<Response, ApiError> {
+ 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<RenderModel> {
+ 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)
+}