use std::path::Path;
use crate::error::{Error, Result};
use super::*;
pub(crate) fn import_foreign(
project: &Path,
language: &str,
file: &Path,
format: crate::cli::LanguageImportFormat,
commit: bool,
) -> Result<()> {
use crate::cli::LanguageImportFormat;
use crate::conlang::interchange;
let (store, _hierarchy, lang_book) = open_lang_book(project, language)?;
let lexemes = match format {
LanguageImportFormat::Toolbox => {
let raw = std::fs::read_to_string(file).map_err(|e| {
Error::Config(format!("could not read {}: {e}", file.display()))
})?;
interchange::parse_toolbox(&raw)
}
LanguageImportFormat::Polyglot => {
let xml = read_polyglot_xml(file)?;
interchange::parse_polyglot(&xml).map_err(Error::Config)?
}
};
if lexemes.is_empty() {
eprintln!(
"no entries found in {} — is it a {} file?",
file.display(),
match format {
LanguageImportFormat::Toolbox => "Toolbox/SFM",
LanguageImportFormat::Polyglot => "PolyGlot",
}
);
return Ok(());
}
if !commit {
eprintln!(
"{} entr{} parsed from {} (preview — pass --yes to import):\n",
lexemes.len(),
if lexemes.len() == 1 { "y" } else { "ies" },
file.display()
);
for lx in lexemes.iter().take(20) {
let pos = if lx.pos.is_empty() {
String::new()
} else {
format!(" [{}]", lx.pos)
};
println!(" {:<20} {}{}", lx.word, lx.translation, pos);
}
if lexemes.len() > 20 {
println!(" … and {} more", lexemes.len() - 20);
}
return Ok(());
}
let cfg = Config::load_layered(&ProjectLayout::new(project).config_path())?;
let (mut added, mut skipped) = (0usize, 0usize);
for lx in &lexemes {
let entry = ImportEntry {
word: lx.word.clone(),
pos: lx.pos.clone(),
translation: lx.translation.clone(),
example: lx.example.clone(),
pronunciation: lx.pronunciation.clone(),
etymology: lx.etymology.clone(),
notes: lx.notes.clone(),
..Default::default()
};
match add_imported_dictionary_entry(&store, &cfg, &lang_book, &entry) {
Ok(_) => added += 1,
Err(e) => {
skipped += 1;
eprintln!(" skipped {}: {e}", lx.word);
}
}
}
eprintln!("\nimported {added} entr(y/ies) into {language}'s Dictionary ({skipped} skipped)");
Ok(())
}
pub(crate) fn read_polyglot_xml(file: &Path) -> Result<String> {
let bytes = std::fs::read(file)
.map_err(|e| Error::Config(format!("could not read {}: {e}", file.display())))?;
let is_zip = bytes.starts_with(b"PK\x03\x04");
if !is_zip {
return String::from_utf8(bytes)
.map_err(|e| Error::Config(format!("{} is not valid UTF-8: {e}", file.display())));
}
let reader = std::io::Cursor::new(bytes);
let mut zip = zip::ZipArchive::new(reader)
.map_err(|e| Error::Config(format!("{} is not a valid .pgd archive: {e}", file.display())))?;
let names: Vec<String> = (0..zip.len())
.filter_map(|i| zip.by_index(i).ok().map(|f| f.name().to_string()))
.collect();
let target = names
.iter()
.find(|n| n.eq_ignore_ascii_case("PGDictionary.xml"))
.or_else(|| names.iter().find(|n| n.to_ascii_lowercase().ends_with(".xml")))
.cloned()
.ok_or_else(|| {
Error::Config(format!(
"no XML dictionary found inside {} (members: {})",
file.display(),
names.join(", ")
))
})?;
let mut member = zip
.by_name(&target)
.map_err(|e| Error::Config(format!("could not read {target} from archive: {e}")))?;
let mut xml = String::new();
std::io::Read::read_to_string(&mut member, &mut xml)
.map_err(|e| Error::Config(format!("could not decode {target}: {e}")))?;
Ok(xml)
}
pub(crate) fn import_dictionary_csv(
project: &Path,
language: &str,
csv_path: &Path,
new: bool,
force: bool,
) -> Result<()> {
use crate::store::node::NodeKind;
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)?;
let hierarchy = Hierarchy::load(&store)?;
let lang_root = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(SYSTEM_TAG_LANGUAGES)
})
.ok_or_else(|| {
Error::Store(
"Language system book missing — re-open the project to seed it".into(),
)
})?
.clone();
let lang_book = hierarchy
.children_of(Some(lang_root.id))
.into_iter()
.find(|n| {
n.kind == NodeKind::Book && n.title.eq_ignore_ascii_case(language)
})
.cloned()
.ok_or_else(|| {
Error::Config(format!(
"language `{language}` not found — run `inkhaven language init {language}` first"
))
})?;
let raw = std::fs::read_to_string(csv_path).map_err(|e| {
Error::Config(format!(
"could not read CSV file {}: {e}",
csv_path.display()
))
})?;
let rows = parse_csv(&raw)
.map_err(|e| Error::Config(format!("CSV parse error: {e}")))?;
let mut rows = rows.into_iter();
let header = rows
.next()
.ok_or_else(|| Error::Config("CSV is empty (no header row)".into()))?;
let columns = resolve_csv_columns(&header)?;
let data_rows: Vec<Vec<String>> = rows.collect();
if !force {
let meta = read_meta_overview(&store, &hierarchy, &lang_book)?;
let phoneme_inventories =
collect_phonology_inventories(&store, &hierarchy, &lang_book)?;
let alphabet: Vec<String> = meta
.as_ref()
.map(|m| m.alphabet.clone())
.unwrap_or_default();
let mut violations: Vec<String> = Vec::new();
for (row_idx, row) in data_rows.iter().enumerate() {
let display_row = row_idx + 2;
let word = row
.get(columns.word)
.cloned()
.unwrap_or_default()
.trim()
.to_string();
if word.is_empty() || word.starts_with('#') {
continue;
}
if !alphabet.is_empty() {
if let Some(bad) = first_unknown_letter(&word, &alphabet) {
violations.push(format!(
"row {display_row}: `{word}` contains `{bad}` not in Meta/overview.alphabet"
));
continue; }
}
if !phoneme_inventories.is_empty() {
if let Some(bad) = first_unknown_letter(&word, &phoneme_inventories) {
violations.push(format!(
"row {display_row}: `{word}` contains `{bad}` not in any Phonology inventory"
));
}
}
}
if !violations.is_empty() {
eprintln!(
"Pre-flight validation failed — {} violation(s) found:\n",
violations.len()
);
for v in &violations {
eprintln!(" · {v}");
}
eprintln!(
"\nFix by either:\n \
· updating Meta/overview.alphabet to include the missing characters, OR\n \
· updating a Phonology rule's `phonemes` list to include them, OR\n \
· correcting the CSV, OR\n \
· re-running with --force to bypass validation."
);
return Err(Error::Config(format!(
"import aborted — {} alphabet/phonology violation(s)",
violations.len()
)));
}
}
if new {
wipe_dictionary(&store, &hierarchy, &lang_book, language)?;
}
let mut imported = 0usize;
let mut skipped_blank = 0usize;
let mut skipped_comment = 0usize;
let mut skipped_duplicate = 0usize;
let mut failed = 0usize;
for (row_idx, row) in data_rows.into_iter().enumerate() {
let display_row = row_idx + 2;
let entry = match build_import_entry_from_row(&columns, &row) {
Ok(e) => e,
Err(e) => {
eprintln!("row {display_row}: {e} — skipped");
failed += 1;
continue;
}
};
let trimmed = entry.word.trim();
if trimmed.is_empty() {
skipped_blank += 1;
continue;
}
if trimmed.starts_with('#') {
skipped_comment += 1;
continue;
}
match add_imported_dictionary_entry(&store, &cfg, &lang_book, &entry) {
Ok((_, bucket)) => {
eprintln!("imported `{}` → {language}/Dictionary/{bucket}", entry.word);
imported += 1;
}
Err(e) => {
let msg = e.to_string();
if msg.contains("already defined") {
eprintln!("row {display_row}: `{}` already exists — skipped", entry.word);
skipped_duplicate += 1;
} else {
eprintln!("row {display_row}: import `{}` failed: {msg}", entry.word);
failed += 1;
}
}
}
}
eprintln!();
eprintln!("Import summary for `{language}`");
eprintln!(" imported: {imported}");
if skipped_blank > 0 {
eprintln!(" skipped (blank): {skipped_blank}");
}
if skipped_comment > 0 {
eprintln!(" skipped (#): {skipped_comment}");
}
if skipped_duplicate > 0 {
eprintln!(" skipped (dup): {skipped_duplicate}");
}
if failed > 0 {
eprintln!(" failed: {failed}");
}
Ok(())
}
pub(crate) struct CsvColumns {
pub(crate) word: usize,
pub(crate) pos: usize,
pub(crate) translation: usize,
pub(crate) example: Option<usize>,
pub(crate) pronunciation: Option<usize>,
pub(crate) etymology: Option<usize>,
pub(crate) related: Option<usize>,
pub(crate) inflection: Option<usize>,
pub(crate) examples: Option<usize>,
pub(crate) register: Option<usize>,
pub(crate) era: Option<usize>,
pub(crate) notes: Option<usize>,
}
pub(crate) fn resolve_csv_columns(header: &[String]) -> Result<CsvColumns> {
let lookup = |name: &str| -> Option<usize> {
header.iter().position(|h| h.trim().eq_ignore_ascii_case(name))
};
let word = lookup("word").ok_or_else(|| {
Error::Config("CSV missing required column `word`".into())
})?;
let pos = lookup("type").ok_or_else(|| {
Error::Config("CSV missing required column `type`".into())
})?;
let translation = lookup("translation").ok_or_else(|| {
Error::Config("CSV missing required column `translation`".into())
})?;
Ok(CsvColumns {
word,
pos,
translation,
example: lookup("example"),
pronunciation: lookup("pronunciation"),
etymology: lookup("etymology"),
related: lookup("related"),
inflection: lookup("inflection"),
examples: lookup("examples"),
register: lookup("register"),
era: lookup("era"),
notes: lookup("notes"),
})
}
pub(crate) fn build_import_entry_from_row(
cols: &CsvColumns,
row: &[String],
) -> std::result::Result<ImportEntry, String> {
let get = |idx: usize| -> String {
row.get(idx).cloned().unwrap_or_default()
};
let opt = |maybe_idx: Option<usize>| -> String {
maybe_idx.map(get).unwrap_or_default()
};
let inflection_raw = opt(cols.inflection);
let inflection = parse_inflection_field(&inflection_raw);
let examples_raw = opt(cols.examples);
let examples = split_pipe(&examples_raw);
let related_raw = opt(cols.related);
let related = split_semicolon(&related_raw);
Ok(ImportEntry {
word: get(cols.word).trim().to_string(),
pos: get(cols.pos).trim().to_string(),
translation: get(cols.translation).trim().to_string(),
example: opt(cols.example).trim().to_string(),
pronunciation: opt(cols.pronunciation).trim().to_string(),
etymology: opt(cols.etymology).trim().to_string(),
related,
inflection,
examples,
register: opt(cols.register).trim().to_string(),
era: opt(cols.era).trim().to_string(),
notes: opt(cols.notes).trim().to_string(),
domain: Vec::new(),
})
}
pub(crate) fn parse_inflection_field(
raw: &str,
) -> std::collections::BTreeMap<String, String> {
let mut out = std::collections::BTreeMap::new();
for pair in raw.split(';') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
if let Some(eq) = pair.find('=') {
let key = pair[..eq].trim().to_string();
let value = pair[eq + 1..].trim().to_string();
if !key.is_empty() && !value.is_empty() {
out.insert(key, value);
}
}
}
out
}
pub(crate) fn split_pipe(raw: &str) -> Vec<String> {
raw.split('|')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub(crate) fn split_semicolon(raw: &str) -> Vec<String> {
raw.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub(crate) fn read_meta_overview(
store: &Store,
hierarchy: &Hierarchy,
lang_book: &crate::store::node::Node,
) -> Result<Option<crate::language_entry::MetaOverview>> {
use crate::store::node::NodeKind;
let Some(meta_chapter) = hierarchy
.children_of(Some(lang_book.id))
.into_iter()
.find(|n| {
n.kind == NodeKind::Chapter && n.title.eq_ignore_ascii_case("Meta")
})
.cloned()
else {
return Ok(None);
};
let Some(overview) = hierarchy
.children_of(Some(meta_chapter.id))
.into_iter()
.find(|n| {
n.kind == NodeKind::Paragraph && n.title.eq_ignore_ascii_case("overview")
})
.cloned()
else {
return Ok(None);
};
let Some(bytes) = store.get_content(overview.id)? else {
return Ok(None);
};
let body = match std::str::from_utf8(&bytes) {
Ok(s) => s,
Err(_) => return Ok(None),
};
Ok(crate::language_entry::parse_meta_overview(body)
.map_err(Error::Config)?)
}
pub(crate) fn collect_phonology_inventories(
store: &Store,
hierarchy: &Hierarchy,
lang_book: &crate::store::node::Node,
) -> Result<Vec<String>> {
use crate::store::node::NodeKind;
use serde::Deserialize;
#[derive(Deserialize)]
struct PhonologyRule {
#[serde(default)]
phonemes: Vec<String>,
}
let Some(phonology) = hierarchy
.children_of(Some(lang_book.id))
.into_iter()
.find(|n| {
n.kind == NodeKind::Chapter && n.title.eq_ignore_ascii_case("Phonology")
})
.cloned()
else {
return Ok(Vec::new());
};
let mut out: Vec<String> = Vec::new();
for id in hierarchy.collect_subtree(phonology.id) {
let Some(node) = hierarchy.get(id) else { continue; };
if node.kind != NodeKind::Paragraph {
continue;
}
let Ok(Some(bytes)) = store.get_content(id) else { continue; };
let Ok(body) = std::str::from_utf8(&bytes) else { continue; };
let parsed: Option<PhonologyRule> = serde_hjson::from_str(body)
.ok()
.or_else(|| {
None
});
if let Some(rule) = parsed {
out.extend(rule.phonemes);
}
}
Ok(out)
}
pub(crate) fn first_unknown_letter(word: &str, inventory: &[String]) -> Option<char> {
let inventory_lower: Vec<String> = inventory
.iter()
.map(|s| s.to_lowercase())
.collect();
for c in word.chars() {
if c.is_whitespace() || c.is_ascii_punctuation() {
continue;
}
let c_lower = c.to_lowercase().collect::<String>();
let found = inventory_lower
.iter()
.any(|entry| entry.contains(&c_lower));
if !found {
return Some(c);
}
}
None
}
pub(crate) fn wipe_dictionary(
store: &Store,
hierarchy: &Hierarchy,
lang_book: &crate::store::node::Node,
language: &str,
) -> Result<()> {
use crate::store::node::NodeKind;
let dictionary = hierarchy
.children_of(Some(lang_book.id))
.into_iter()
.find(|n| {
n.kind == NodeKind::Chapter && n.title.eq_ignore_ascii_case("Dictionary")
})
.cloned()
.ok_or_else(|| {
Error::Config(format!(
"language `{language}` has no Dictionary chapter to wipe"
))
})?;
let buckets: Vec<_> =
hierarchy.children_of(Some(dictionary.id)).into_iter().cloned().collect();
let bucket_count = buckets.len();
let mut entry_count = 0usize;
let dummy_layout = ProjectLayout::new(store.project_root());
for bucket in buckets.into_iter().rev() {
let fresh = Hierarchy::load(store)?;
let ids = fresh.collect_subtree(bucket.id);
entry_count += ids.len().saturating_sub(1);
let Some(refreshed_bucket) = fresh.get(bucket.id) else { continue; };
let fs_rel = fresh.fs_path(refreshed_bucket, &dummy_layout);
store
.delete_subtree(&fs_rel, &ids)
.map_err(|e| Error::Store(format!("wipe bucket `{}`: {e}", bucket.title)))?;
}
eprintln!(
"--new: wiped {entry_count} existing entries across {bucket_count} buckets from `{language}/Dictionary`"
);
Ok(())
}
pub(crate) fn parse_csv(raw: &str) -> std::result::Result<Vec<Vec<String>>, String> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut row: Vec<String> = Vec::new();
let mut field = String::new();
let mut in_quoted = false;
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
if in_quoted {
match c {
'"' => {
if chars.peek() == Some(&'"') {
chars.next();
field.push('"');
} else {
in_quoted = false;
}
}
_ => field.push(c),
}
} else {
match c {
'"' => in_quoted = true,
',' => {
row.push(std::mem::take(&mut field));
}
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
row.push(std::mem::take(&mut field));
rows.push(std::mem::take(&mut row));
}
'\n' => {
row.push(std::mem::take(&mut field));
rows.push(std::mem::take(&mut row));
}
_ => field.push(c),
}
}
}
if in_quoted {
return Err("unclosed quote at end of file".into());
}
if !field.is_empty() || !row.is_empty() {
row.push(field);
rows.push(row);
}
Ok(rows)
}