use std::collections::HashSet;
use serde_norway::Value;
use crate::commands::maybe_sync;
use crate::doc::Doc;
use crate::error::{usage, Result};
use crate::frontmatter::Frontmatter;
use crate::project::Project;
use crate::project_config::{DocType, ProjectConfig};
use crate::{rules, Ctx};
pub fn run(ctx: &Ctx, type_name: &str, file: &str) -> Result<()> {
let prj = ctx.open()?;
let (mut store, _) = ctx.load(&prj)?;
let pcfg = &prj.pcfg;
let ft = pcfg.types.get(type_name).ok_or_else(|| {
let mut names: Vec<&str> = pcfg.types.keys().map(String::as_str).collect();
names.sort_unstable();
usage(format!(
"unknown type {type_name:?} (configured: {})",
names.join(", ")
))
})?;
let doc_ids: HashSet<String> = store.doc_ids()?;
let text = std::fs::read_to_string(file)
.map_err(|e| usage(format!("cannot read import file {file:?}: {e}")))?;
let mut next = store.max_doc_num()?;
let mut built: Vec<Doc> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for (i, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
next += 1;
let id = format!("{}-{:0pad$}", ft.prefix, next, pad = pcfg.pad);
match build_record(&prj, ft, pcfg, type_name, &doc_ids, &id, line) {
Ok(d) => built.push(d),
Err(e) => errors.push(format!("line {}: {e}", i + 1)),
}
}
if !errors.is_empty() {
return Err(usage(format!(
"import aborted — {} record(s) rejected, no files written:\n {}",
errors.len(),
errors.join("\n ")
)));
}
if built.is_empty() {
return Err(usage(format!("no records found in {file:?}")));
}
for d in &built {
store.put_doc(pcfg, None, d)?;
}
let first = built.first().and_then(Doc::id).unwrap_or("");
let last = built.last().and_then(Doc::id).unwrap_or("");
println!(
"imported {} {type_name} document(s): {first}..{last}",
built.len()
);
ctx.flush(&prj, store)?;
maybe_sync(ctx, &prj);
Ok(())
}
fn build_record(
prj: &Project,
ft: &DocType,
pcfg: &ProjectConfig,
type_name: &str,
doc_ids: &HashSet<String>,
id: &str,
line: &str,
) -> Result<Doc> {
let value: Value =
serde_norway::from_str(line).map_err(|e| usage(format!("not a valid JSON object: {e}")))?;
let Value::Mapping(map) = value else {
return Err(usage("expected a JSON object"));
};
let mut fm = Frontmatter::new();
fm.set_str("id", id);
let mut title: Option<String> = None;
let mut extra_body: Option<String> = None;
for (k, v) in map {
let Some(key) = k.as_str() else {
return Err(usage("record has a non-string key"));
};
match key {
"id" => return Err(usage("id is auto-allocated — remove it from the record")),
"title" => title = Some(as_string(&v, "title")?),
"body" => extra_body = Some(as_string(&v, "body")?),
_ => {
fm.insert(key, v);
}
}
}
let title = title.ok_or_else(|| usage("missing required \"title\""))?;
if title.trim().is_empty() {
return Err(usage("\"title\" must not be empty"));
}
if ft.tags_required && !fm.tags_is_nonempty_list() {
return Err(usage("\"tags\" must be a non-empty array of strings"));
}
if fm.contains_key("status") && fm.status().is_none() {
return Err(usage("\"status\" must be a string"));
}
if !fm.contains_key("status") {
fm.set_str("status", &ft.default_status);
}
let status = fm.status().expect("status set above").to_string();
if !ft.statuses.iter().any(|s| s == &status) {
return Err(usage(format!(
"unknown status {status:?} (allowed: {})",
ft.statuses.join(", ")
)));
}
let body = match extra_body {
Some(extra) if !extra.trim().is_empty() => {
format!("# {title}\n\n{}\n", extra.trim_matches('\n'))
}
_ => format!("# {title}\n"),
};
let problems = rules::evaluate(pcfg, type_name, &status, &fm, &body, doc_ids);
if !problems.is_empty() {
return Err(usage(problems.join("; ")));
}
Ok(Doc {
path: prj.doc_path(id, &status),
frontmatter: fm,
body,
title,
})
}
fn as_string(v: &Value, field: &str) -> Result<String> {
v.as_str()
.map(str::to_string)
.ok_or_else(|| usage(format!("\"{field}\" must be a string")))
}