use super::files::move_files;
use super::names::{name_of, strip_number};
use super::notes::body_notes;
use super::types::{ImportPlan, Row};
use crate::authoring::Plan;
use crate::authoring::import::convert::Converted;
use crate::authoring::import::map::{ImportMap, SourceRule};
use crate::authoring::import::sources::Unit;
use crate::authoring::import::{body_meta, convert, key, owner};
use crate::corpus::Corpus;
use crate::model::doctype::{Form, KeyKind, TypeDef};
use crate::model::markdown::{body, frontmatter};
use crate::model::{DocId, Tier, stamp};
use crate::workspace::placement::paths;
use anyhow::{Result, anyhow};
use regex::{Captures, Regex};
use serde_json::{Map, Value, json};
use std::collections::BTreeSet;
struct Source<'a> {
generated: bool,
text: String,
unresolved: Vec<String>,
conv: Converted,
title: String,
first_heading: Option<String>,
caps: Option<Captures<'a>>,
name: &'a str,
named: &'a str,
}
struct Placed {
k: String,
slug: String,
id: DocId,
owner_reason: String,
main: String,
}
pub(super) fn doc_row(
c: &Corpus,
map: &ImportMap,
u: &Unit,
taken: &mut BTreeSet<String>,
out: &mut ImportPlan,
) -> Result<Row> {
let rule = &map.sources[u.rule];
let ty_name = rule.ty.clone().ok_or_else(|| anyhow!("source {} needs a type", rule.glob))?;
let ty = c.cfg.registry.get(&ty_name).ok_or_else(|| anyhow!("unknown type {ty_name}"))?;
let mut src = read_source(c, map, u, ty)?;
let p = place(c, u, rule, ty, &src, taken)?;
let bundle_dir = p.main.rsplit_once('/').map(|x| x.0.to_string()).unwrap_or_default();
let base = if u.source == u.main { u.main.trim_end_matches(".md").to_string() } else { u.source.clone() };
let files = move_files(c, u, &base, &bundle_dir, out)?;
let (fm, aliases) = frontmatter_of(&mut src, &p, files);
let mut notes = std::mem::take(&mut src.conv.notes);
let body = body_of(map, &src, &p, &mut notes);
let content = format!("{}{}", frontmatter::render(&crate::model::markdown::frontmatter::edit::ordered(fm)), body);
crate::authoring::forbid::refuse(c, &p.main, crate::authoring::forbid::NO_LOCAL_HINT)?;
out.plan.write(p.main.clone(), one_ending(content), true);
out.plan.remove(u.source.clone());
for old in std::iter::once(u.source.clone())
.chain((u.main != u.source).then(|| u.main.clone()))
.chain(u.chapters.iter().cloned())
.chain(u.attachments.iter().cloned())
.chain(aliases.iter().cloned())
{
out.alias_rows.push((old, p.id.to_string()));
}
Ok(Row {
source: u.source.clone(),
kind: "doc".into(),
id: p.id.to_string(),
path: p.main,
owner_reason: p.owner_reason,
status_from: src.conv.status_from,
status_to: fm_status(&out.plan, &p.id.to_string()),
aliases,
unresolved: src.unresolved,
notes,
replaced_by: src.conv.replaced_by,
old_main: if src.generated { String::new() } else { u.main.clone() },
})
}
fn read_source<'a>(c: &Corpus, map: &ImportMap, u: &'a Unit, ty: &TypeDef) -> Result<Source<'a>> {
let rule = &map.sources[u.rule];
let generated = u.main.is_empty();
let text = if generated { String::new() } else { std::fs::read_to_string(c.cfg.spec_home.join(&u.main))? };
let split = frontmatter::split(&text);
let mut unresolved = vec![];
let old = match frontmatter::parse(split.raw) {
frontmatter::Parsed::Map(m) => m,
frontmatter::Parsed::None => Map::new(),
frontmatter::Parsed::Invalid(e) => {
unresolved.push(format!("old frontmatter does not parse: {e}"));
Map::new()
}
};
let mut old = old;
let body_links = if generated { vec![] } else { merge_body_meta(split.body, map, &mut old, &mut unresolved) };
if ty.fields.iter().any(|f| f == "decision_makers") {
let body = if generated { "" } else { split.body };
body_meta::people(&mut old, body);
}
let scanned = body::scan(split.body, split.body_line, &c.cfg.path_cite_re());
let name = name_of(&u.source).trim_end_matches(".md");
let name_re = rule.name.as_deref().map(Regex::new).transpose().map_err(|e| anyhow!("bad name regex: {e}"))?;
let caps = name_re.as_ref().and_then(|r| r.captures(name_of(&u.source)));
let named = caps.as_ref().and_then(|c| c.name("slug")).map(|m| m.as_str()).unwrap_or(name);
let heading = scanned.first_heading.clone().map(|h| if map.strip_title_number { strip_number(&h) } else { h });
let fallback_title = heading.unwrap_or_else(|| named.replace(['-', '_'], " "));
let mut conv = convert::frontmatter(map, rule, &old, &ty.statuses, &fallback_title);
unresolved.append(&mut conv.unresolved);
add_body_links(&mut conv.fm, body_links);
let title = conv.fm.get("title").and_then(Value::as_str).unwrap_or(&fallback_title).to_string();
let first_heading = scanned.first_heading;
Ok(Source { generated, text, unresolved, conv, title, first_heading, caps, name, named })
}
fn place(
c: &Corpus,
u: &Unit,
rule: &SourceRule,
ty: &TypeDef,
src: &Source,
taken: &mut BTreeSet<String>,
) -> Result<Placed> {
let created = src.conv.fm.get("created").and_then(Value::as_str).map(str::to_string);
let key_src = key::Source {
path: &u.main,
caps: src.caps.as_ref(),
created: created.as_deref(),
title: &src.title,
name_stem: src.name,
};
let (k, slug) = key::choose(&c.cfg.spec_home, rule, ty, &key_src);
let governs: Vec<String> = src
.conv
.fm
.get("rel")
.and_then(|r| r.get("governs"))
.map(|v| crate::model::doc::list_of(Some(v)))
.unwrap_or_default();
let (owner, owner_reason) = match rule.owner.as_str() {
"auto" => {
let home = c.owners.storage_owner(c.owners.containing(&u.source));
owner::choose(&c.owners, &governs, &src.text, home)
}
fixed => {
let o = c.owners.by_id(fixed).ok_or_else(|| anyhow!("source {}: no owner '{fixed}'", rule.glob))?;
(o, format!("fixed to {fixed} by the import map"))
}
};
let k = unique_key(ty, &owner.id, k, taken);
let slug = if ty.key == KeyKind::Slug { k.clone() } else { slug };
let id = DocId::new(&ty.name, &owner.id, &k);
taken.insert(id.to_string());
let bundle = !u.chapters.is_empty() || !u.attachments.is_empty() || ty.form == Form::Dir;
let dir = paths::type_dir(&c.cfg, &c.owners, owner, Tier::Committed, ty);
let main = paths::main_file(&dir, ty, &paths::stem(ty, &k, &slug), bundle);
Ok(Placed { k, slug, id, owner_reason, main })
}
fn frontmatter_of(src: &mut Source, p: &Placed, files: Vec<Value>) -> (Map<String, Value>, Vec<String>) {
let mut fm = std::mem::take(&mut src.conv.fm);
fm.insert("id".into(), Value::String(p.id.to_string()));
fm.entry("created").or_insert_with(|| Value::String(stamp::normalize_date(&p.k).unwrap_or_else(stamp::today)));
let created_v = fm["created"].clone();
let updated = fm.entry("updated").or_insert(created_v.clone());
if updated.as_str() < created_v.as_str() {
*updated = created_v;
}
let mut aliases = std::mem::take(&mut src.conv.aliases);
if let Some(a) = src.caps.as_ref().and_then(|c| c.name("alias")) {
aliases.push(a.as_str().to_string());
}
if !aliases.is_empty() {
fm.insert("aliases".into(), json!(aliases));
}
if !files.is_empty() {
fm.insert("files".into(), Value::Array(files));
}
(fm, aliases)
}
fn body_of(map: &ImportMap, src: &Source, p: &Placed, notes: &mut Vec<String>) -> String {
let title = &src.title;
let mut body =
if src.generated { format!("\n# {title}\n") } else { frontmatter::split(&src.text).body.to_string() };
if let Some(name) = map.body.status.as_deref().and_then(|h| h.strip_prefix("section:"))
&& let Some(rest) = without_section(&body, name)
{
if map.body.drop_status_section {
body = rest;
} else {
notes.push(format!(
"the body keeps '## {name}' next to frontmatter status; [body] drop_status_section = true removes it"
));
}
}
notes.extend(body_notes(map, &body, src.generated, src.first_heading.as_deref(), src.named, &p.slug, &p.id));
body
}
fn merge_body_meta(
body: &str,
map: &ImportMap,
old: &mut Map<String, Value>,
unresolved: &mut Vec<String>,
) -> Vec<(String, String)> {
let found = body_meta::scan(body, &map.body);
let has_status = old.contains_key("status");
let has_date = old.contains_key("date") || old.contains_key("created");
if let Some(st) = found.status
&& !old.contains_key("status")
{
old.insert("status".into(), Value::String(st));
}
if let Some(d) = found.date
&& !old.contains_key("date")
&& !old.contains_key("created")
{
old.insert("date".into(), Value::String(d));
}
unresolved.extend(found.missed.into_iter().filter(|m| {
!(has_status && m.starts_with("no status in the body") || has_date && m.starts_with("no date in the body"))
}));
found.links
}
fn add_body_links(fm: &mut Map<String, Value>, body_links: Vec<(String, String)>) {
if body_links.is_empty() {
return;
}
let rel = fm.entry("rel").or_insert_with(|| Value::Object(Map::new()));
if let Value::Object(rel) = rel {
for (kind, target) in body_links {
let name = target.rsplit('/').next().unwrap_or(&target).trim_end_matches(".md").to_string();
if let Value::Array(items) = rel.entry(kind).or_insert_with(|| Value::Array(vec![]))
&& !items.iter().any(|v| v.as_str() == Some(&name))
{
items.push(Value::String(name));
}
}
}
}
fn unique_key(ty: &TypeDef, owner: &str, mut k: String, taken: &BTreeSet<String>) -> String {
loop {
let id = DocId::new(&ty.name, owner, &k).to_string();
if !taken.contains(&id) {
break;
}
k = match ty.key {
KeyKind::Slug => format!("{k}-2"),
kind => kind.next_after(&k).unwrap_or(k),
};
}
k
}
fn fm_status(plan: &Plan, _id: &str) -> String {
plan.changes
.iter()
.rev()
.find_map(|c| match c {
crate::authoring::Change::Write { content, .. } => {
content.lines().find_map(|l| l.strip_prefix("status: ").map(str::to_string))
}
_ => None,
})
.unwrap_or_default()
}
pub(super) fn without_section(body: &str, name: &str) -> Option<String> {
let is_it = |l: &str| l.starts_with('#') && l.trim_start_matches('#').trim().eq_ignore_ascii_case(name);
let lines: Vec<&str> = body.split_inclusive('\n').collect();
let at = lines.iter().position(|l| is_it(l.trim()))?;
let end = lines[at + 1..].iter().position(|l| l.starts_with('#')).map_or(lines.len(), |e| at + 1 + e);
Some([&lines[..at], &lines[end..]].concat().concat())
}
pub(super) fn one_ending(content: String) -> String {
if content.contains("\r\n") { content.replace("\r\n", "\n").replace('\n', "\r\n") } else { content }
}