use std::path::Path;
use anyhow::{anyhow, Result};
use crate::cli::TimelineExportFormat;
use crate::config::Config;
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{EventData, Node, NodeKind};
use crate::timeline::{Calendar, TimelinePoint};
pub fn run(
project: &Path,
book_name: Option<&str>,
format: TimelineExportFormat,
output: &Path,
track_filter: Option<&str>,
) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
if !cfg.timeline.enabled {
return Err(anyhow!(
"`inkhaven export-timeline` requires `timeline.enabled: true` in inkhaven.hjson"
));
}
let store = Store::open(layout.clone(), &cfg)?;
let calendar = Calendar::from_config(cfg.timeline.calendar.clone());
let hierarchy = Hierarchy::load(&store)?;
let book = crate::cli::resolve_user_book(&hierarchy, book_name, "export-timeline")
.map_err(|m| anyhow!(m))?;
let book_id = book.id;
let book_title = book.title.clone();
let mut rows: Vec<(&Node, &EventData)> = hierarchy
.flatten()
.into_iter()
.filter_map(|(n, _)| n.event.as_ref().map(|e| (n, e)))
.filter(|(n, _)| {
let mut cur = *n;
loop {
if cur.kind == NodeKind::Book {
return cur.id == book_id;
}
let Some(pid) = cur.parent_id else { return false };
match hierarchy.get(pid) {
Some(p) => cur = p,
None => return false,
}
}
})
.collect();
if let Some(track) = track_filter {
rows.retain(|(_, ev)| {
ev.track
.as_deref()
.map(|t| t.eq_ignore_ascii_case(track))
.unwrap_or(false)
});
}
rows.sort_by_key(|(_, ev)| ev.start_ticks);
let default_track = cfg.timeline.default_track.clone();
match format {
TimelineExportFormat::Typst => {
let body = render_typst(
&book_title,
track_filter,
&default_track,
&rows,
&calendar,
);
std::fs::write(output, body.as_bytes())
.map_err(|e| anyhow!("write {}: {e}", output.display()))?;
}
TimelineExportFormat::Svg => {
let svg = render_svg(
&book_title,
track_filter,
&default_track,
&rows,
&calendar,
);
std::fs::write(output, svg.as_bytes())
.map_err(|e| anyhow!("write {}: {e}", output.display()))?;
}
TimelineExportFormat::Png => {
let svg = render_svg(
&book_title,
track_filter,
&default_track,
&rows,
&calendar,
);
let png = svg_to_png_bytes(&svg)
.map_err(|e| anyhow!("PNG rasterise: {e}"))?;
std::fs::write(output, &png)
.map_err(|e| anyhow!("write {}: {e}", output.display()))?;
}
}
eprintln!(
"exported {} event{} from `{}` → {}",
rows.len(),
if rows.len() == 1 { "" } else { "s" },
book_title,
output.display(),
);
Ok(())
}
fn render_typst(
book_title: &str,
track_filter: Option<&str>,
default_track: &str,
rows: &[(&Node, &EventData)],
calendar: &Calendar,
) -> String {
let mut out = String::new();
out.push_str(&format!("// Inkhaven 1.2.8+ — timeline export\n"));
out.push_str(&format!("// book: {book_title}\n"));
if let Some(t) = track_filter {
out.push_str(&format!("// track filter: {t}\n"));
}
out.push_str(&format!("// events: {}\n\n", rows.len()));
out.push_str(&format!("= {book_title} — timeline\n\n"));
if rows.is_empty() {
out.push_str("_No events match._\n");
return out;
}
let mut tracks: Vec<String> = Vec::new();
for (_, ev) in rows {
let t = ev
.track
.clone()
.unwrap_or_else(|| default_track.to_string());
if !tracks.contains(&t) {
tracks.push(t);
}
}
for track in &tracks {
out.push_str(&format!("== {track}\n\n"));
for (n, ev) in rows {
let evt_track = ev
.track
.clone()
.unwrap_or_else(|| default_track.to_string());
if evt_track != *track {
continue;
}
let start = calendar.format(
TimelinePoint::from_ticks(ev.start_ticks),
ev.precision,
);
let end_label = match ev.end_ticks {
Some(t) => {
let s = calendar
.format(TimelinePoint::from_ticks(t), ev.precision);
format!(" — {s}")
}
None => String::new(),
};
let orphan = if n.tags.iter().any(|t| t == "orphan") {
" _[orphan]_"
} else {
""
};
out.push_str(&format!(
"- *{start}{end_label}* — {title}{orphan}\n",
title = n.title,
));
if !n.linked_paragraphs.is_empty() {
out.push_str(&format!(
" // {} linked paragraph(s)\n",
n.linked_paragraphs.len()
));
}
}
out.push('\n');
}
out
}
const SVG_CANVAS_W: u32 = 1200;
const SVG_LEFT_MARGIN: u32 = 140;
const SVG_RIGHT_PAD: u32 = 40;
const SVG_TITLE_H: u32 = 36;
const SVG_DATE_ROW_H: u32 = 28;
const SVG_TRACK_H: u32 = 44;
const SVG_BOTTOM_PAD: u32 = 24;
fn render_svg(
book_title: &str,
track_filter: Option<&str>,
default_track: &str,
rows: &[(&Node, &EventData)],
calendar: &Calendar,
) -> String {
let mut tracks: Vec<String> = Vec::new();
for (_, ev) in rows {
let t = ev
.track
.clone()
.unwrap_or_else(|| default_track.to_string());
if !tracks.contains(&t) {
tracks.push(t);
}
}
let canvas_h = SVG_TITLE_H
+ SVG_DATE_ROW_H
+ (tracks.len().max(1) as u32) * SVG_TRACK_H
+ SVG_BOTTOM_PAD;
let mut out = String::with_capacity(2048 + rows.len() * 200);
out.push_str(&format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="{w}" height="{h}" viewBox="0 0 {w} {h}" font-family="Helvetica, Arial, sans-serif">
"##,
w = SVG_CANVAS_W,
h = canvas_h,
));
out.push_str(&format!(
r##" <rect width="{}" height="{}" fill="#ffffff"/>
"##,
SVG_CANVAS_W, canvas_h
));
let title_text = match track_filter {
Some(t) => format!("{} — timeline (track: {})", book_title, t),
None => format!("{} — timeline", book_title),
};
out.push_str(&format!(
r##" <text x="{x}" y="{y}" font-size="18" font-weight="bold" fill="#222">{title}</text>
"##,
x = 16,
y = SVG_TITLE_H - 12,
title = escape_svg(&title_text),
));
if rows.is_empty() {
out.push_str(&format!(
r##" <text x="{x}" y="{y}" font-size="14" fill="#888" font-style="italic">No events match</text>
"##,
x = 16,
y = SVG_TITLE_H + 24,
));
out.push_str("</svg>\n");
return out;
}
let min_tick = rows.iter().map(|(_, e)| e.start_ticks).min().unwrap_or(0);
let max_tick = rows
.iter()
.map(|(_, e)| e.end_ticks.unwrap_or(e.start_ticks).max(e.start_ticks))
.max()
.unwrap_or(min_tick);
let span = (max_tick - min_tick).max(1);
let plot_w = SVG_CANVAS_W - SVG_LEFT_MARGIN - SVG_RIGHT_PAD;
let x_of = |tick: i64| -> f64 {
SVG_LEFT_MARGIN as f64
+ ((tick - min_tick) as f64) * (plot_w as f64) / (span as f64)
};
let date_y = SVG_TITLE_H + SVG_DATE_ROW_H - 10;
let axis_y = SVG_TITLE_H + SVG_DATE_ROW_H;
let n_labels: usize = 6;
for i in 0..=n_labels {
let tick = min_tick + (span * i as i64) / (n_labels as i64);
let xp = x_of(tick);
out.push_str(&format!(
r##" <line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="#aaa" stroke-width="1"/>
"##,
x = xp,
y1 = axis_y - 4,
y2 = axis_y + 4,
));
let label = calendar.format(
crate::timeline::TimelinePoint::from_ticks(tick),
crate::timeline::Precision::Day,
);
out.push_str(&format!(
r##" <text x="{x}" y="{y}" font-size="11" fill="#444" text-anchor="middle">{label}</text>
"##,
x = xp,
y = date_y,
label = escape_svg(&label),
));
}
out.push_str(&format!(
r##" <line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="#bbb" stroke-width="1"/>
"##,
x1 = SVG_LEFT_MARGIN,
x2 = SVG_CANVAS_W - SVG_RIGHT_PAD,
y = axis_y,
));
for (row_i, track) in tracks.iter().enumerate() {
let row_top = SVG_TITLE_H + SVG_DATE_ROW_H + (row_i as u32) * SVG_TRACK_H;
let row_mid = row_top + SVG_TRACK_H / 2;
if row_i > 0 {
out.push_str(&format!(
r##" <line x1="0" y1="{y}" x2="{w}" y2="{y}" stroke="#eee" stroke-width="1"/>
"##,
y = row_top,
w = SVG_CANVAS_W,
));
}
out.push_str(&format!(
r##" <text x="{x}" y="{y}" font-size="13" fill="#333" text-anchor="end" font-weight="bold">{label}</text>
"##,
x = SVG_LEFT_MARGIN - 12,
y = row_mid + 4,
label = escape_svg(track),
));
out.push_str(&format!(
r##" <line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="#f0f0f0" stroke-width="1"/>
"##,
x1 = SVG_LEFT_MARGIN,
x2 = SVG_CANVAS_W - SVG_RIGHT_PAD,
y = row_mid,
));
for (n, ev) in rows {
let evt_track = ev
.track
.clone()
.unwrap_or_else(|| default_track.to_string());
if &evt_track != track {
continue;
}
let xs = x_of(ev.start_ticks);
let is_orphan = n.tags.iter().any(|t| t == "orphan");
let primary = if is_orphan { "#888" } else { "#3a7fd5" };
let dash = if is_orphan {
r##" stroke-dasharray="3,3""##
} else {
""
};
match ev.end_ticks {
Some(end_t) if end_t > ev.start_ticks => {
let xe = x_of(end_t);
let bar_h = 12.0_f64;
out.push_str(&format!(
r##" <rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{c}" fill-opacity="0.25" stroke="{c}"{dash}/>
"##,
x = xs,
y = row_mid as f64 - bar_h / 2.0,
w = (xe - xs).max(2.0),
h = bar_h,
c = primary,
dash = dash,
));
}
_ => {
out.push_str(&format!(
r##" <circle cx="{x}" cy="{y}" r="5" fill="{c}" stroke="#fff" stroke-width="1"/>
"##,
x = xs,
y = row_mid,
c = primary,
));
}
}
let max_chars = 32;
let label: String = if n.title.chars().count() > max_chars {
let mut s: String = n.title.chars().take(max_chars - 1).collect();
s.push('…');
s
} else {
n.title.clone()
};
out.push_str(&format!(
r##" <text x="{x}" y="{y}" font-size="10" fill="#222">{label}</text>
"##,
x = xs + 6.0,
y = row_mid as f64 - 9.0,
label = escape_svg(&label),
));
}
}
out.push_str("</svg>\n");
out
}
fn escape_svg(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn svg_to_png_bytes(svg: &str) -> Result<Vec<u8>, String> {
use resvg::{tiny_skia, usvg};
let opts = usvg::Options::default();
let tree =
usvg::Tree::from_str(svg, &opts).map_err(|e| format!("svg parse: {e}"))?;
let int_size = tree.size().to_int_size();
let (w, h) = (int_size.width(), int_size.height());
if w == 0 || h == 0 {
return Err("rendered SVG has zero size".into());
}
let mut pixmap = tiny_skia::Pixmap::new(w, h)
.ok_or_else(|| format!("cannot allocate {w}×{h} pixmap"))?;
pixmap.fill(tiny_skia::Color::WHITE);
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
pixmap.encode_png().map_err(|e| format!("encode PNG: {e}"))
}