feat(atlas): add sync-wiki command, sync Gateway body catalog to wiki

New atlas subcommand: sync-wiki generates a Celestial Bodies section
in wiki pages from DB body/station data. All fields rendered: orbit,
ID, name, type, inhabited, population, mass, gravity, atmosphere,
biome, hydrosphere, economy, settlement pattern, industrial corridor.
Stations table includes docking class, governance, gate flag, districts.

Moons render indented under their parent body (↳ notation).
Section is idempotent — re-running overwrites the existing section.

DB is source of truth, wiki is generated output. No import direction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 18:19:29 +01:00
co-authored by Claude Opus 4.6
parent 080732d2ac
commit b77c94a6d8
2 changed files with 327 additions and 0 deletions
+307
View File
@@ -147,6 +147,14 @@ enum Commands {
},
/// List systems that have no bodies yet
Unfinished,
/// Sync body/station data into wiki page for a system (or all systems)
SyncWiki {
/// System ID (omit for all systems with bodies)
system_id: Option<String>,
/// Path to wiki directory (default: wiki/star-systems/)
#[arg(long, default_value = "wiki/star-systems")]
wiki: String,
},
}
// ---------------------------------------------------------------------------
@@ -1233,6 +1241,302 @@ fn cmd_unfinished(conn: &Connection) {
println!("{}", serde_json::to_string_pretty(&result).unwrap());
}
fn cmd_sync_wiki(conn: &Connection, system_id: Option<&str>, wiki_dir: &str) {
// Get list of systems to sync
let system_ids: Vec<String> = if let Some(sid) = system_id {
vec![sid.to_string()]
} else {
let mut stmt = conn
.prepare("SELECT DISTINCT system_id FROM bodies ORDER BY system_id")
.unwrap();
stmt.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect()
};
let mut synced = 0;
for sid in &system_ids {
let sid_slug = sid.replace(' ', "-");
let wiki_path = std::path::Path::new(wiki_dir).join(&sid_slug).join("index.md");
if !wiki_path.exists() {
eprintln!("skip: {} — no wiki page at {}", sid, wiki_path.display());
continue;
}
// Query bodies
let mut body_stmt = conn
.prepare(
"SELECT body_id, proper_name, body_type, orbit_index, parent_body_id,
inhabited, population, mass_class, atmosphere, surface_gravity,
biome_summary, hydrosphere, economic_role, settlement_pattern,
industrial_corridor
FROM bodies WHERE system_id = ?1
ORDER BY CASE WHEN parent_body_id IS NULL THEN orbit_index ELSE 1000 + orbit_index END",
)
.unwrap();
struct Body {
id: String,
name: Option<String>,
btype: String,
orbit: i32,
parent: Option<String>,
inhabited: bool,
population: i64,
mass_class: Option<String>,
atmosphere: Option<String>,
gravity: Option<f64>,
biome: Option<String>,
hydro: Option<String>,
econ: Option<String>,
settlement: Option<String>,
industrial: Option<String>,
}
let bodies: Vec<Body> = body_stmt
.query_map(params![sid], |row| {
Ok(Body {
id: row.get(0)?,
name: row.get(1)?,
btype: row.get(2)?,
orbit: row.get::<_, Option<i32>>(3)?.unwrap_or(0),
parent: row.get(4)?,
inhabited: row.get::<_, i32>(5)? != 0,
population: row.get::<_, Option<i64>>(6)?.unwrap_or(0),
mass_class: row.get(7)?,
atmosphere: row.get(8)?,
gravity: row.get(9)?,
biome: row.get(10)?,
hydro: row.get(11)?,
econ: row.get(12)?,
settlement: row.get(13)?,
industrial: row.get(14)?,
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
// Query stations
let mut station_stmt = conn
.prepare(
"SELECT station_id, proper_name, orbits_body_id, station_type,
population, economic_role, governance_type, docking_class,
has_gate_infrastructure, district_count
FROM stations WHERE system_id = ?1
ORDER BY station_id",
)
.unwrap();
struct Station {
id: String,
name: Option<String>,
orbits: Option<String>,
stype: String,
population: i64,
econ: Option<String>,
governance: Option<String>,
docking: Option<String>,
gate: bool,
districts: i32,
}
let stations: Vec<Station> = station_stmt
.query_map(params![sid], |row| {
Ok(Station {
id: row.get(0)?,
name: row.get(1)?,
orbits: row.get(2)?,
stype: row.get(3)?,
population: row.get::<_, Option<i64>>(4)?.unwrap_or(0),
econ: row.get(5)?,
governance: row.get(6)?,
docking: row.get(7)?,
gate: row.get::<_, Option<i32>>(8)?.unwrap_or(0) != 0,
districts: row.get::<_, Option<i32>>(9)?.unwrap_or(1),
})
})
.unwrap()
.filter_map(|r| r.ok())
.collect();
if bodies.is_empty() && stations.is_empty() {
continue;
}
// Build the Celestial Bodies section
let mut section = String::new();
section.push_str("## Celestial Bodies\n");
section.push_str("<!-- READ-ONLY — generated from systems.db bodies/stations tables -->\n\n");
// Bodies table
if !bodies.is_empty() {
section.push_str("| Orbit | ID | Name | Type | Inhabited | Population | Mass | Gravity | Atmosphere | Biome | Hydrosphere | Economy | Settlement | Industrial |\n");
section.push_str("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|\n");
// First pass: top-level bodies (no parent)
for b in &bodies {
if b.parent.is_some() {
continue;
}
let name = b.name.as_deref().unwrap_or("");
let pop = if b.population > 0 {
format_population(b.population)
} else {
"".to_string()
};
let grav = b.gravity.map(|g| format!("{:.2}g", g)).unwrap_or_else(|| "".to_string());
section.push_str(&format!(
"| {} | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
b.orbit,
b.id,
name,
b.btype,
if b.inhabited { "yes" } else { "no" },
pop,
b.mass_class.as_deref().unwrap_or(""),
grav,
b.atmosphere.as_deref().unwrap_or(""),
b.biome.as_deref().unwrap_or(""),
b.hydro.as_deref().unwrap_or(""),
b.econ.as_deref().unwrap_or(""),
b.settlement.as_deref().unwrap_or(""),
b.industrial.as_deref().unwrap_or(""),
));
// Child bodies (moons)
for m in &bodies {
if m.parent.as_deref() == Some(&b.id) {
let mname = m.name.as_deref().unwrap_or("");
let mpop = if m.population > 0 {
format_population(m.population)
} else {
"".to_string()
};
let mgrav = m.gravity.map(|g| format!("{:.2}g", g)).unwrap_or_else(|| "".to_string());
section.push_str(&format!(
"| ↳ {}.{} | `{}` | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
b.orbit,
m.orbit,
m.id,
mname,
m.btype,
if m.inhabited { "yes" } else { "no" },
mpop,
m.mass_class.as_deref().unwrap_or(""),
mgrav,
m.atmosphere.as_deref().unwrap_or(""),
m.biome.as_deref().unwrap_or(""),
m.hydro.as_deref().unwrap_or(""),
m.econ.as_deref().unwrap_or(""),
m.settlement.as_deref().unwrap_or(""),
m.industrial.as_deref().unwrap_or(""),
));
}
}
}
section.push('\n');
}
// Stations table
if !stations.is_empty() {
section.push_str("### Stations & Facilities\n\n");
section.push_str("| ID | Name | Type | Orbits | Population | Economy | Governance | Docking | Gate | Districts |\n");
section.push_str("|---|---|---|---|---|---|---|---|---|---|\n");
for s in &stations {
let name = s.name.as_deref().unwrap_or("");
let orbits = s.orbits.as_deref().unwrap_or("");
let pop = if s.population > 0 {
format_population(s.population)
} else {
"".to_string()
};
section.push_str(&format!(
"| `{}` | {} | {} | `{}` | {} | {} | {} | {} | {} | {} |\n",
s.id,
name,
s.stype,
orbits,
pop,
s.econ.as_deref().unwrap_or(""),
s.governance.as_deref().unwrap_or(""),
s.docking.as_deref().unwrap_or(""),
if s.gate { "yes" } else { "no" },
s.districts,
));
}
section.push('\n');
}
// Read current wiki content
let content = std::fs::read_to_string(&wiki_path).unwrap();
// Replace or insert the Celestial Bodies section
let marker_start = "## Celestial Bodies";
let new_content = if let Some(start_pos) = content.find(marker_start) {
// Find the next ## heading after the section (or end of file)
let after = &content[start_pos + marker_start.len()..];
let end_offset = after
.find("\n## ")
.map(|p| start_pos + marker_start.len() + p + 1)
.unwrap_or(content.len());
format!("{}{}\n{}", &content[..start_pos], section.trim_end(), &content[end_offset..])
} else {
// Insert after the System Profile section (after the first "---" pair)
// Find the second "---" which ends the System Profile
let mut dashes = 0;
let mut insert_pos = None;
for (i, line) in content.lines().enumerate() {
if line.trim() == "---" {
dashes += 1;
if dashes == 3 {
// Third --- is end of System Profile
let byte_pos: usize = content.lines().take(i + 1).map(|l| l.len() + 1).sum();
insert_pos = Some(byte_pos);
break;
}
}
}
if let Some(pos) = insert_pos {
format!("{}\n{}\n---\n\n{}", &content[..pos], section.trim_end(), &content[pos..])
} else {
// Fallback: append at end
format!("{}\n{}", content, section)
}
};
std::fs::write(&wiki_path, new_content).unwrap();
eprintln!("synced: {}{}", sid, wiki_path.display());
synced += 1;
}
#[derive(Serialize)]
struct SyncResult {
systems_synced: usize,
}
println!(
"{}",
serde_json::to_string_pretty(&SyncResult {
systems_synced: synced
})
.unwrap()
);
}
fn format_population(pop: i64) -> String {
if pop >= 1_000_000_000 {
format!("{:.1}B", pop as f64 / 1_000_000_000.0)
} else if pop >= 1_000_000 {
format!("{}M", pop / 1_000_000)
} else if pop >= 1_000 {
format!("{}K", pop / 1_000)
} else {
format!("{}", pop)
}
}
// cmd_populate removed — replaced by per-system author/commit workflow
// ---------------------------------------------------------------------------
@@ -1261,5 +1565,8 @@ fn main() {
Commands::CommitSystem { path } => cmd_commit_system(&conn, path),
Commands::WipeSystem { system_id } => cmd_wipe_system(&conn, system_id),
Commands::Unfinished => cmd_unfinished(&conn),
Commands::SyncWiki { system_id, wiki } => {
cmd_sync_wiki(&conn, system_id.as_deref(), wiki)
}
}
}
+20
View File
@@ -66,3 +66,23 @@ The Institute has been studying the first horizon station for five hundred years
<!-- READ-ONLY — regenerated from star-map.json edges -->
- **Hop Distance from Gateway:** 0
- **Adjacent Systems:** GJ 0, GJ 559B, GJ 144, GJ 244A, GJ 725B
## Celestial Bodies
<!-- READ-ONLY — generated from systems.db bodies/stations tables -->
| Orbit | ID | Name | Type | Inhabited | Population | Mass | Gravity | Atmosphere | Biome | Hydrosphere | Economy | Settlement | Industrial |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | `GJ71b` | — | planet | no | — | terrestrial | — | none | barren | none | — | — | — |
| 2 | `GJ71c` | Threshold | planet | yes | 600M | terrestrial | — | breathable | temperate | ocean | agricultural | urban_concentrated | — |
| 3 | `GJ71d` | Arden | planet | yes | 500M | terrestrial | — | breathable | temperate | rivers | agricultural | dispersed_rural | — |
| ↳ 3.1 | `GJ71d-1` | Verantis | moon | yes | 20M | dwarf | — | none | barren | none | transit | domed | — |
| 4 | `GJ71e` | — | planet | no | — | terrestrial | — | thin | frozen | ice | — | — | — |
| 5 | `GJ71-belt` | — | asteroid_belt | no | — | — | — | — | — | — | — | — | — |
| 6 | `GJ71-oort` | — | oort_cloud | no | — | — | — | — | — | — | — | — | — |
### Stations & Facilities
| ID | Name | Type | Orbits | Population | Economy | Governance | Docking | Gate | Districts |
|---|---|---|---|---|---|---|---|---|---|
| `GJ71-oort-S1` | Horizon Station | horizon | `GJ71-oort` | 80M | transit | — | major | yes | 1 |