use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const REFERENCES_VERSION: u32 = 1;
const IMAGE_EXTS: [&str; 5] = ["png", "jpg", "jpeg", "webp", "gif"];
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reference {
pub id: String,
pub path: String,
pub caption: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct References {
pub version: u32,
pub refs: Vec<Reference>,
#[serde(default)]
pub issued: u32,
}
impl Default for References {
fn default() -> Self {
References { version: REFERENCES_VERSION, refs: Vec::new(), issued: 0 }
}
}
pub fn path_for_map(map_path: &Path) -> PathBuf {
let name = map_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let stem = name.strip_suffix(".map.ron").or_else(|| name.strip_suffix(".ron")).unwrap_or(&name);
map_path.with_file_name(format!("{stem}.references.ron"))
}
fn normalise(path: &str) -> Result<String, String> {
let mut parts = Vec::new();
for c in Path::new(path).components() {
match c {
Component::Normal(p) => parts.push(p.to_string_lossy().into_owned()),
Component::CurDir => {}
_ => return Err(format!("references: {path:?} must be a path inside the project")),
}
}
let Some(last) = parts.last() else {
return Err("references: a reference needs a path".to_owned());
};
let ext = last.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
if !IMAGE_EXTS.contains(&ext.as_str()) {
return Err(format!("references: {path:?} is not an image ({})", IMAGE_EXTS.join(", ")));
}
Ok(parts.join("/"))
}
impl References {
pub fn from_ron(text: &str) -> Result<References, String> {
let r: References = ron::from_str(text).map_err(|e| format!("references: parse: {e}"))?;
if r.version != REFERENCES_VERSION {
return Err(format!("references: version {} is not the {REFERENCES_VERSION} this build reads", r.version));
}
Ok(r)
}
pub fn to_ron(&self) -> Result<String, String> {
let mut text = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
.map_err(|e| format!("references: serialize: {e}"))?;
if !text.ends_with('\n') {
text.push('\n');
}
Ok(text)
}
pub fn load(path: &Path) -> Result<References, String> {
match std::fs::read_to_string(path) {
Ok(text) => References::from_ron(&text).map_err(|e| format!("{}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(References::default()),
Err(e) => Err(format!("references: {}: {e}", path.display())),
}
}
pub fn save(&self, path: &Path) -> Result<(), String> {
if self.refs.is_empty() && !path.exists() {
return Ok(());
}
crate::ron_surgery::save_atomic(path, &self.to_ron()?)
}
pub fn get(&self, id: &str) -> Option<&Reference> {
self.refs.iter().find(|r| r.id == id)
}
pub fn add(&mut self, path: &str, caption: &str) -> Result<String, String> {
let path = normalise(path)?;
if let Some(r) = self.refs.iter().find(|r| r.path == path) {
return Err(format!("references: {path} is already {}", r.id));
}
let issued = self.issued.max(self.refs.iter().filter_map(|r| r.id[1..].parse().ok()).max().unwrap_or(0)) + 1;
self.issued = issued;
let id = format!("r{issued}");
self.refs.push(Reference { id: id.clone(), path, caption: caption.trim().to_owned() });
Ok(id)
}
pub fn remove(&mut self, id: &str) -> Result<Reference, String> {
let i = self.refs.iter().position(|r| r.id == id).ok_or_else(|| format!("references: no reference {id}"))?;
Ok(self.refs.remove(i))
}
pub fn cite(&self, id: &str) -> Result<String, String> {
let r = self.get(id).ok_or_else(|| format!("references: no reference {id}"))?;
Ok(format!("reference {} ({}): {}", r.id, r.path, r.caption))
}
pub fn unknown_citations<'a>(&self, texts: impl IntoIterator<Item = &'a str>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for text in texts {
let mut rest = text;
while let Some(at) = rest.find("reference r") {
rest = &rest[at + "reference ".len()..];
let digits: String = rest[1..].chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
continue;
}
let id = format!("r{digits}");
if self.get(&id).is_none() && !out.contains(&id) {
out.push(id);
}
}
}
out
}
pub fn missing(&self, root: &Path) -> Vec<(String, String)> {
self.refs.iter().filter(|r| !root.join(&r.path).is_file()).map(|r| (r.id.clone(), r.path.clone())).collect()
}
}