use crate::args::Args;
use crate::event::{Body, Event, Kind, State};
use crate::failure::{Failure, R};
use crate::ops::Ctx;
use crate::output::outln;
use crate::{id, redact};
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Deserialize)]
struct Old {
nodes: BTreeMap<String, OldNode>,
}
#[derive(Deserialize)]
struct OldNode {
id: u64,
title: String,
kind: String,
status: String,
parent: Option<u64>,
#[serde(default)]
why: String,
#[serde(default)]
outcome: String,
#[serde(default)]
note: String,
#[serde(default)]
refs: Vec<String>,
#[serde(default)]
blocks: bool,
#[serde(default)]
opened: String,
#[serde(default)]
closed: Option<String>,
}
fn kind_of(kind: &str) -> Kind {
match kind {
"goal" => Kind::Goal,
"decision" => Kind::Decision,
"finding" => Kind::Finding,
_ => Kind::Task,
}
}
fn state_of(status: &str) -> State {
match status {
"done" => State::Done,
"parked" => State::Suspended,
"superseded" => State::Superseded,
_ => State::Active,
}
}
fn digits(b: &[u8], ranges: &[std::ops::Range<usize>]) -> bool {
ranges.iter().all(|r| {
b.get(r.clone())
.is_some_and(|d| d.iter().all(u8::is_ascii_digit))
})
}
fn parse_calendar(s: &str) -> Option<(i64, u32, u32)> {
let b = s.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' || !digits(b, &[0..4, 5..7, 8..10]) {
return None;
}
let y: i64 = s[0..4].parse().ok()?;
let m: u32 = s[5..7].parse().ok()?;
let d: u32 = s[8..10].parse().ok()?;
crate::clock::is_valid_civil_date(y, m, d).then_some((y, m, d))
}
fn instant(date: &str) -> Option<String> {
if date.len() == 10 {
parse_calendar(date)?;
return Some(format!("{date}T12:00:00Z"));
}
let b = date.as_bytes();
if b.len() < 20 || !date.is_ascii() {
return None;
}
let (y, m, d) = parse_calendar(&date[0..10])?;
if b[10] != b'T' || b[13] != b':' || b[16] != b':' || !digits(b, &[11..13, 14..16, 17..19]) {
return None;
}
let hh: i64 = date[11..13].parse().ok()?;
let mm: i64 = date[14..16].parse().ok()?;
let ss: i64 = date[17..19].parse().ok()?;
if hh >= 24 || mm >= 60 || ss >= 60 {
return None;
}
let mut idx = 19;
if b.get(idx).copied() == Some(b'.') {
idx += 1;
let start = idx;
while b.get(idx).copied().is_some_and(|c| c.is_ascii_digit()) {
idx += 1;
}
if idx == start {
return None;
}
}
let offset_secs: i64 = match b.get(idx).copied() {
Some(b'Z') if idx + 1 == b.len() => 0,
Some(b'+') | Some(b'-') => {
let sign = if b[idx] == b'-' { -1 } else { 1 };
let rest = &date[idx + 1..];
if rest.len() != 5
|| rest.as_bytes()[2] != b':'
|| !digits(rest.as_bytes(), &[0..2, 3..5])
{
return None;
}
let offset_hh: i64 = rest[0..2].parse().ok()?;
let offset_mm: i64 = rest[3..5].parse().ok()?;
if offset_hh > 23 || offset_mm > 59 {
return None;
}
sign * (offset_hh * 3_600 + offset_mm * 60)
}
_ => return None,
};
let local_secs = crate::clock::days_from_civil((y, m, d)) * 86_400 + hh * 3_600 + mm * 60 + ss;
let utc_secs = local_secs - offset_secs;
let (utc_year, utc_month, utc_day) = crate::clock::civil_from_days(utc_secs.div_euclid(86_400));
let rem = utc_secs.rem_euclid(86_400);
Some(format!(
"{utc_year:04}-{utc_month:02}-{utc_day:02}T{:02}:{:02}:{:02}Z",
rem / 3_600,
(rem % 3_600) / 60,
rem % 60
))
}
pub fn import(ctx: &mut Ctx, args: &Args) -> R {
let file_path = args
.positional(0)
.ok_or_else(|| Failure::usage("usage: vivac import <path to tree.json>"))?;
if !ctx.tree.is_empty_tree() {
return Err(Failure::Model(format!(
" The tree already has {} nodes. Importing on top would duplicate numbers.\n\n \
Import into a freshly created .vivac/.",
ctx.tree.total()
)));
}
let raw = std::fs::read_to_string(file_path)?;
let old: Old = serde_json::from_str(&raw)
.map_err(|e| Failure::usage(format!("{file_path} is not a spike tree.json: {e}")))?;
let mut nodes: Vec<&OldNode> = old.nodes.values().collect();
nodes.sort_by_key(|n| n.id);
for n in &nodes {
let fields: Vec<(&str, &str)> = vec![
("title", &n.title),
("why", &n.why),
("outcome", &n.outcome),
("note", &n.note),
];
if let Some(mut h) = redact::check_fields(&fields) {
h.field = format!("node #{} ({})", n.id, h.field);
return Err(Failure::Redaction(Box::new(h)));
}
if let Some(mut h) = n.refs.iter().find_map(|r| redact::check_field("ref", r)) {
h.field = format!("node #{} (ref)", n.id);
return Err(Failure::Redaction(Box::new(h)));
}
}
let mut bad_dates: Vec<String> = Vec::new();
for n in &nodes {
if instant(&n.opened).is_none() {
bad_dates.push(format!("#{} opened {:?}", n.id, n.opened));
}
if let Some(closed) = &n.closed {
if instant(closed).is_none() {
bad_dates.push(format!("#{} closed {:?}", n.id, closed));
}
}
}
if !bad_dates.is_empty() {
return Err(Failure::usage(format!(
"import cannot place these dates on the timeline, so nothing was\n \
written:\n\n {}\n\n \
Accepted: a real YYYY-MM-DD date, or a full RFC 3339 date-time -- a T\n \
separator, optional fractional seconds, and a Z or +HH:MM/-HH:MM offset.",
bad_dates.join("\n ")
)));
}
let ulids: BTreeMap<u64, String> = nodes.iter().map(|n| (n.id, id::ulid())).collect();
ctx.lock_for_write()?;
if !ctx.tree.is_empty_tree() {
return Err(Failure::Model(format!(
" The tree already has {} nodes. Importing on top would duplicate numbers.\n\n \
Import into a freshly created .vivac/.",
ctx.tree.total()
)));
}
let mut events = Vec::new();
let mut seq = ctx.tree.seq;
let actor = ctx.store.config.actor.clone();
let lane = ctx
.lane
.clone()
.unwrap_or_else(|| crate::lane::MAIN.to_string());
let mut push_event = |body: Body, ts: String| {
seq += 1;
events.push(Event {
seq,
id: id::ulid(),
ts,
actor: actor.clone(),
lane: lane.clone(),
payload: body,
});
};
for n in &nodes {
push_event(
Body::NodeCreated {
node: ulids[&n.id].clone(),
num: n.id,
kind: kind_of(&n.kind),
title: n.title.clone(),
why: n.why.clone(),
parent: n.parent.and_then(|p| ulids.get(&p).cloned()),
blocks: n.blocks,
refs: n.refs.clone(),
governs: vec![],
arms: vec![],
against: None,
},
instant(&n.opened).expect("validated in the pre-pass above"),
);
}
for n in &nodes {
if !n.note.is_empty() {
push_event(
Body::NodeNoted {
node: ulids[&n.id].clone(),
note: n.note.clone(),
},
instant(&n.opened).expect("validated in the pre-pass above"),
);
}
let state = state_of(&n.status);
if state != State::Active {
push_event(
Body::StateChanged {
node: ulids[&n.id].clone(),
state,
outcome: n.outcome.clone(),
forced: false,
},
instant(n.closed.as_deref().unwrap_or(&n.opened))
.expect("validated in the pre-pass above"),
);
}
}
let total = nodes.len();
let lock = ctx
.lock
.as_ref()
.ok_or_else(|| Failure::Io(std::io::Error::other("write without the tree's lock")))?;
ctx.store.write_raw(lock, &events)?;
outln!(" {total} nodes imported from {file_path}");
outln!(" {} events written to .vivac/events", events.len());
outln!();
outln!(" Review what the spike could not see: vivac check");
Ok(())
}