laterite_web/theme/
archive.rs1use super::{engine, store, Kind, Theme, ThemeConfig, ThemeError};
12use laterite_core::Db;
13use std::io::{Cursor, Read, Write};
14use zip::write::SimpleFileOptions;
15
16const SEPARATOR: &str = "==";
18const MAX_BYTES: usize = 32 * 1024 * 1024;
20
21#[derive(Debug, Default, Clone, PartialEq, Eq)]
23pub struct ImportReport {
24 pub created: bool,
26 pub imported: usize,
28 pub skipped: usize,
30 pub ignored: Vec<String>,
32}
33
34pub fn file_body(settings: &serde_json::Value, markup: &str) -> String {
36 match settings.as_object() {
37 Some(map) if !map.is_empty() => {
38 let yaml = serde_saphyr::to_string(settings).unwrap_or_default();
39 format!("{}\n{SEPARATOR}\n{markup}", yaml.trim_end())
40 }
41 _ => markup.to_string(),
42 }
43}
44
45pub fn parse_body(body: &str) -> Result<(serde_json::Value, String), ThemeError> {
48 let body = body.replace("\r\n", "\n");
49 let Some(at) = body
50 .split_inclusive('\n')
51 .scan(0usize, |offset, line| {
52 let start = *offset;
53 *offset += line.len();
54 Some((start, line))
55 })
56 .find(|(_, line)| line.trim_end() == SEPARATOR)
57 .map(|(start, _)| start)
58 else {
59 return Ok((serde_json::json!({}), body));
60 };
61 let settings: serde_json::Value = serde_saphyr::from_str(&body[..at])
62 .map_err(|e| ThemeError::Config(format!("the settings above `==` do not parse: {e}")))?;
63 if !settings.is_object() && !settings.is_null() {
64 return Err(ThemeError::Config(
65 "the settings above `==` must be a mapping".into(),
66 ));
67 }
68 let settings = if settings.is_null() {
69 serde_json::json!({})
70 } else {
71 settings
72 };
73 let rest = &body[at..];
74 let markup = rest.split_once('\n').map(|(_, m)| m).unwrap_or("");
75 Ok((settings, markup.to_string()))
76}
77
78pub async fn export(db: &Db, code: &str) -> Result<Vec<u8>, ThemeError> {
80 let theme = store::find_theme(db, code)
81 .await?
82 .ok_or_else(|| ThemeError::NotFound(code.to_string()))?;
83 let templates = store::list_templates(db, theme.id, None).await?;
84 let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
85 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
86 zip.start_file("theme.yaml", options)?;
87 zip.write_all(theme.config_source.as_bytes())?;
88 if theme
89 .settings
90 .as_object()
91 .is_some_and(|map| !map.is_empty())
92 {
93 zip.start_file("settings.json", options)?;
94 zip.write_all(
95 serde_json::to_string_pretty(&theme.settings)
96 .unwrap_or_default()
97 .as_bytes(),
98 )?;
99 }
100 for t in templates {
101 zip.start_file(engine::template_name(t.kind, &t.path), options)?;
102 zip.write_all(file_body(&t.settings, &t.markup).as_bytes())?;
103 }
104 Ok(zip.finish()?.into_inner())
105}
106
107fn kind_of(folder: &str) -> Option<Kind> {
109 Kind::ALL
110 .into_iter()
111 .find(|k| engine::template_name(*k, "") == format!("{folder}/"))
112}
113
114pub async fn import(
119 db: &Db,
120 code: &str,
121 bytes: &[u8],
122 replace: bool,
123 saved_by: Option<i64>,
124) -> Result<ImportReport, ThemeError> {
125 let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?;
126 let mut config: Option<String> = None;
127 let mut settings: Option<serde_json::Value> = None;
128 let mut files: Vec<(Kind, String, String)> = Vec::new();
129 let mut report = ImportReport::default();
130 let mut total = 0usize;
131 for i in 0..archive.len() {
132 let mut file = archive.by_index(i)?;
133 if file.is_dir() {
134 continue;
135 }
136 let Some(path) = file.enclosed_name() else {
137 report.ignored.push(file.name()?.to_string());
138 continue;
139 };
140 let name = path.to_string_lossy().replace('\\', "/");
141 let mut body = String::new();
142 total += file.size() as usize;
143 if total > MAX_BYTES {
144 return Err(ThemeError::Config("the archive is too large".into()));
145 }
146 file.read_to_string(&mut body)
147 .map_err(|_| ThemeError::Config(format!("{name} is not UTF-8 text")))?;
148 match name.as_str() {
149 "theme.yaml" => config = Some(body),
150 "settings.json" => {
151 settings = Some(serde_json::from_str(&body).map_err(|e| {
152 ThemeError::Config(format!("settings.json does not parse: {e}"))
153 })?)
154 }
155 _ => match name.split_once('/') {
156 Some((folder, rest)) if !rest.is_empty() => match kind_of(folder) {
157 Some(kind) => files.push((kind, rest.to_string(), body)),
158 None => report.ignored.push(name),
159 },
160 _ => report.ignored.push(name),
161 },
162 }
163 }
164 let theme: Theme = match store::find_theme(db, code).await? {
165 Some(theme) => {
166 if theme.locked {
167 return Err(ThemeError::Locked(code.to_string()));
168 }
169 match (&config, replace) {
170 (Some(yaml), true) => store::update_theme_config(db, code, yaml).await?,
171 _ => theme,
172 }
173 }
174 None => {
175 let yaml = config.clone().ok_or_else(|| {
176 ThemeError::Config("the archive has no theme.yaml to create the theme from".into())
177 })?;
178 ThemeConfig::parse(&yaml)?;
179 report.created = true;
180 store::create_theme(db, code, &yaml).await?
181 }
182 };
183 for (kind, path, body) in files {
184 if !super::valid_path(&path) {
185 return Err(ThemeError::Path(path));
186 }
187 let exists = store::find_template(db, theme.id, kind, &path)
188 .await?
189 .is_some();
190 if exists && !replace {
191 report.skipped += 1;
192 continue;
193 }
194 let (settings, markup) = parse_body(&body)?;
195 store::save_template(db, theme.id, kind, &path, Some(settings), &markup, saved_by).await?;
196 report.imported += 1;
197 }
198 if let Some(values) = settings {
199 if report.created || replace {
200 store::save_settings(db, code, &values).await?;
201 }
202 }
203 Ok(report)
204}