use super::{engine, store, Kind, Theme, ThemeConfig, ThemeError};
use laterite_core::Db;
use std::io::{Cursor, Read, Write};
use zip::write::SimpleFileOptions;
const SEPARATOR: &str = "==";
const MAX_BYTES: usize = 32 * 1024 * 1024;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ImportReport {
pub created: bool,
pub imported: usize,
pub skipped: usize,
pub ignored: Vec<String>,
}
pub fn file_body(settings: &serde_json::Value, markup: &str) -> String {
match settings.as_object() {
Some(map) if !map.is_empty() => {
let yaml = serde_saphyr::to_string(settings).unwrap_or_default();
format!("{}\n{SEPARATOR}\n{markup}", yaml.trim_end())
}
_ => markup.to_string(),
}
}
pub fn parse_body(body: &str) -> Result<(serde_json::Value, String), ThemeError> {
let body = body.replace("\r\n", "\n");
let Some(at) = body
.split_inclusive('\n')
.scan(0usize, |offset, line| {
let start = *offset;
*offset += line.len();
Some((start, line))
})
.find(|(_, line)| line.trim_end() == SEPARATOR)
.map(|(start, _)| start)
else {
return Ok((serde_json::json!({}), body));
};
let settings: serde_json::Value = serde_saphyr::from_str(&body[..at])
.map_err(|e| ThemeError::Config(format!("the settings above `==` do not parse: {e}")))?;
if !settings.is_object() && !settings.is_null() {
return Err(ThemeError::Config(
"the settings above `==` must be a mapping".into(),
));
}
let settings = if settings.is_null() {
serde_json::json!({})
} else {
settings
};
let rest = &body[at..];
let markup = rest.split_once('\n').map(|(_, m)| m).unwrap_or("");
Ok((settings, markup.to_string()))
}
pub async fn export(db: &Db, code: &str) -> Result<Vec<u8>, ThemeError> {
let theme = store::find_theme(db, code)
.await?
.ok_or_else(|| ThemeError::NotFound(code.to_string()))?;
let templates = store::list_templates(db, theme.id, None).await?;
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
zip.start_file("theme.yaml", options)?;
zip.write_all(theme.config_source.as_bytes())?;
if theme
.settings
.as_object()
.is_some_and(|map| !map.is_empty())
{
zip.start_file("settings.json", options)?;
zip.write_all(
serde_json::to_string_pretty(&theme.settings)
.unwrap_or_default()
.as_bytes(),
)?;
}
for t in templates {
zip.start_file(engine::template_name(t.kind, &t.path), options)?;
zip.write_all(file_body(&t.settings, &t.markup).as_bytes())?;
}
Ok(zip.finish()?.into_inner())
}
fn kind_of(folder: &str) -> Option<Kind> {
Kind::ALL
.into_iter()
.find(|k| engine::template_name(*k, "") == format!("{folder}/"))
}
pub async fn import(
db: &Db,
code: &str,
bytes: &[u8],
replace: bool,
saved_by: Option<i64>,
) -> Result<ImportReport, ThemeError> {
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?;
let mut config: Option<String> = None;
let mut settings: Option<serde_json::Value> = None;
let mut files: Vec<(Kind, String, String)> = Vec::new();
let mut report = ImportReport::default();
let mut total = 0usize;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
if file.is_dir() {
continue;
}
let Some(path) = file.enclosed_name() else {
report.ignored.push(file.name()?.to_string());
continue;
};
let name = path.to_string_lossy().replace('\\', "/");
let mut body = String::new();
total += file.size() as usize;
if total > MAX_BYTES {
return Err(ThemeError::Config("the archive is too large".into()));
}
file.read_to_string(&mut body)
.map_err(|_| ThemeError::Config(format!("{name} is not UTF-8 text")))?;
match name.as_str() {
"theme.yaml" => config = Some(body),
"settings.json" => {
settings = Some(serde_json::from_str(&body).map_err(|e| {
ThemeError::Config(format!("settings.json does not parse: {e}"))
})?)
}
_ => match name.split_once('/') {
Some((folder, rest)) if !rest.is_empty() => match kind_of(folder) {
Some(kind) => files.push((kind, rest.to_string(), body)),
None => report.ignored.push(name),
},
_ => report.ignored.push(name),
},
}
}
let theme: Theme = match store::find_theme(db, code).await? {
Some(theme) => {
if theme.locked {
return Err(ThemeError::Locked(code.to_string()));
}
match (&config, replace) {
(Some(yaml), true) => store::update_theme_config(db, code, yaml).await?,
_ => theme,
}
}
None => {
let yaml = config.clone().ok_or_else(|| {
ThemeError::Config("the archive has no theme.yaml to create the theme from".into())
})?;
ThemeConfig::parse(&yaml)?;
report.created = true;
store::create_theme(db, code, &yaml).await?
}
};
for (kind, path, body) in files {
if !super::valid_path(&path) {
return Err(ThemeError::Path(path));
}
let exists = store::find_template(db, theme.id, kind, &path)
.await?
.is_some();
if exists && !replace {
report.skipped += 1;
continue;
}
let (settings, markup) = parse_body(&body)?;
store::save_template(db, theme.id, kind, &path, Some(settings), &markup, saved_by).await?;
report.imported += 1;
}
if let Some(values) = settings {
if report.created || replace {
store::save_settings(db, code, &values).await?;
}
}
Ok(report)
}