1use crate::asset::{Asset, ImageFormat};
16use crate::{Book, FORMAT_VERSION};
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value};
19use std::ffi::OsString;
20use std::fs::{self, File};
21use std::io::{BufReader, BufWriter, Read, Seek, Write};
22use std::path::{Path, PathBuf};
23use zip::write::SimpleFileOptions;
24use zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter};
25
26const BOOK_JSON: &str = "book.json";
27const META_JSON: &str = "meta.json";
28const ASSETS_DIR: &str = "assets/";
29
30pub const MAX_JSON_BYTES: u64 = 256 * 1024 * 1024;
32pub const MAX_ASSET_BYTES: u64 = 100 * 1024 * 1024;
34
35pub const EXTENSION: &str = "kernsetzer";
37pub const OLD_EXTENSION: &str = "setzer";
40
41pub const CREATED_WITH: &str = concat!("kernsetzer ", env!("CARGO_PKG_VERSION"));
43
44#[derive(Debug, thiserror::Error)]
45pub enum ProjectError {
46 #[error("invalid JSON: {0}")]
47 Json(#[from] serde_json::Error),
48 #[error("book.json has no valid format_version")]
49 MissingVersion,
50 #[error(
51 "this project was saved by a newer Kernsetzer (format {found}, this version reads up to {max}). Update Kernsetzer to open it.",
52 max = FORMAT_VERSION
53 )]
54 TooNew { found: u64 },
55 #[error("this is not a Kernsetzer project (no book.json inside)")]
56 NotAProject,
57 #[error("the project file is damaged: {0}")]
58 Zip(#[from] zip::result::ZipError),
59 #[error("{name} in the project is larger than {limit} bytes")]
60 TooLarge { name: String, limit: u64 },
61 #[error("\"{0}\" cannot be used as an image file name in a project")]
62 InvalidAssetId(String),
63 #[error("two images in the project are both named \"{0}\"")]
64 DuplicateAsset(String),
65 #[error(transparent)]
66 Io(#[from] std::io::Error),
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct Meta {
72 pub format_version: u32,
74 #[serde(default)]
77 pub created_with: String,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub modified: Option<String>,
81 #[serde(flatten)]
83 pub extra: Map<String, Value>,
84}
85
86impl Meta {
87 pub fn new() -> Self {
89 Self {
90 format_version: FORMAT_VERSION,
91 created_with: CREATED_WITH.into(),
92 modified: None,
93 extra: Map::new(),
94 }
95 }
96}
97
98impl Default for Meta {
99 fn default() -> Self {
100 Self::new()
101 }
102}
103
104#[derive(Debug, Clone, PartialEq)]
106pub struct Project {
107 pub book: Book,
108 pub assets: Vec<Asset>,
110 pub meta: Meta,
111}
112
113impl Project {
114 pub fn new(book: Book, assets: Vec<Asset>) -> Self {
115 Self {
116 book,
117 assets,
118 meta: Meta::new(),
119 }
120 }
121
122 pub fn asset(&self, id: &str) -> Option<&Asset> {
123 self.assets.iter().find(|a| a.id == id)
124 }
125
126 pub fn read<R: Read + Seek>(reader: R) -> Result<Self, ProjectError> {
132 let mut zip = ZipArchive::new(reader)?;
133 let mut book = None;
134 let mut meta = None;
135 let mut assets: Vec<Asset> = Vec::new();
136 for i in 0..zip.len() {
137 let mut file = zip.by_index(i)?;
138 if file.is_dir() {
139 continue;
140 }
141 let name = file.name()?.into_owned();
142 if name == BOOK_JSON {
143 let bytes = read_limited(&mut file, &name, MAX_JSON_BYTES)?;
144 book = Some(book_from_json(&String::from_utf8_lossy(&bytes))?);
145 } else if name == META_JSON {
146 let bytes = read_limited(&mut file, &name, MAX_JSON_BYTES)?;
147 meta = serde_json::from_slice::<Meta>(&bytes).ok();
148 } else if let Some(id) = name.strip_prefix(ASSETS_DIR) {
149 if !Asset::is_valid_id(id) || assets.iter().any(|a| a.id == id) {
150 continue;
151 }
152 let id = id.to_owned();
153 let bytes = read_limited(&mut file, &name, MAX_ASSET_BYTES)?;
154 assets.push(Asset::new(id, bytes));
155 }
156 }
157 let meta = meta.unwrap_or_else(|| Meta {
158 created_with: "unknown".into(),
159 ..Meta::new()
160 });
161 if meta.format_version > FORMAT_VERSION {
162 return Err(ProjectError::TooNew {
163 found: meta.format_version.into(),
164 });
165 }
166 Ok(Self {
167 book: book.ok_or(ProjectError::NotAProject)?,
168 assets,
169 meta,
170 })
171 }
172
173 pub fn write<W: Write + Seek>(&self, writer: W) -> Result<W, ProjectError> {
175 self.write_with(writer, self.meta.modified.as_deref())
176 }
177
178 fn write_with<W: Write + Seek>(
179 &self,
180 writer: W,
181 modified: Option<&str>,
182 ) -> Result<W, ProjectError> {
183 let mut seen: Vec<&str> = Vec::with_capacity(self.assets.len());
184 for a in &self.assets {
185 if !Asset::is_valid_id(&a.id) {
186 return Err(ProjectError::InvalidAssetId(a.id.clone()));
187 }
188 if seen.contains(&a.id.as_str()) {
189 return Err(ProjectError::DuplicateAsset(a.id.clone()));
190 }
191 seen.push(&a.id);
192 }
193 let meta = Meta {
194 format_version: FORMAT_VERSION,
195 modified: modified.map(str::to_owned),
196 ..self.meta.clone()
197 };
198
199 let opts = SimpleFileOptions::default().last_modified_time(DateTime::DEFAULT);
202 let deflated = opts.compression_method(CompressionMethod::Deflated);
203 let stored = opts.compression_method(CompressionMethod::Stored);
204 let mut zip = ZipWriter::new(writer);
205 zip.start_file(META_JSON, deflated)?;
206 zip.write_all(serde_json::to_string_pretty(&meta)?.as_bytes())?;
207 zip.start_file(BOOK_JSON, deflated)?;
208 zip.write_all(book_to_json(&self.book)?.as_bytes())?;
209 for a in &self.assets {
210 let method = match ImageFormat::sniff(&a.bytes) {
212 ImageFormat::Supported { ext, .. } if ext != "svg" => stored,
213 _ => deflated,
214 };
215 zip.start_file(format!("{ASSETS_DIR}{}", a.id), method)?;
216 zip.write_all(&a.bytes)?;
217 }
218 Ok(zip.finish()?)
219 }
220
221 pub fn open(path: &Path) -> Result<Self, ProjectError> {
222 Self::read(BufReader::new(File::open(path)?))
223 }
224
225 pub fn save(&mut self, path: &Path) -> Result<(), ProjectError> {
228 let now = now_rfc3339();
229 write_atomically(path, |f| self.write_with(f, Some(&now)).map(drop))?;
230 self.meta.modified = Some(now);
231 discard_autosave(path)
232 }
233
234 pub fn autosave(&self, path: &Path) -> Result<(), ProjectError> {
237 let now = now_rfc3339();
238 write_atomically(&autosave_path(path), |f| {
239 self.write_with(f, Some(&now)).map(drop)
240 })
241 }
242}
243
244pub fn autosave_path(project: &Path) -> PathBuf {
248 with_suffix(project, ".autosave")
249}
250
251pub fn discard_autosave(project: &Path) -> Result<(), ProjectError> {
253 match fs::remove_file(autosave_path(project)) {
254 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
255 _ => Ok(()),
256 }
257}
258
259fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
260 let mut s = OsString::from(path.as_os_str());
261 s.push(suffix);
262 PathBuf::from(s)
263}
264
265fn write_atomically(
268 path: &Path,
269 write: impl FnOnce(&mut BufWriter<File>) -> Result<(), ProjectError>,
270) -> Result<(), ProjectError> {
271 let tmp = with_suffix(path, ".saving");
272 let result = (|| {
273 let mut out = BufWriter::new(File::create(&tmp)?);
274 write(&mut out)?;
275 out.into_inner().map_err(|e| e.into_error())?.sync_all()?;
276 fs::rename(&tmp, path)?;
277 Ok(())
278 })();
279 if result.is_err() {
280 let _ = fs::remove_file(&tmp);
281 }
282 result
283}
284
285fn read_limited(file: &mut impl Read, name: &str, limit: u64) -> Result<Vec<u8>, ProjectError> {
286 let mut bytes = Vec::new();
287 file.take(limit + 1).read_to_end(&mut bytes)?;
288 if bytes.len() as u64 > limit {
289 return Err(ProjectError::TooLarge {
290 name: name.into(),
291 limit,
292 });
293 }
294 Ok(bytes)
295}
296
297pub fn now_rfc3339() -> String {
299 let secs = std::time::SystemTime::now()
300 .duration_since(std::time::UNIX_EPOCH)
301 .map_or(0, |d| d.as_secs());
302 rfc3339(secs)
303}
304
305fn rfc3339(unix_secs: u64) -> String {
306 let days = (unix_secs / 86_400) as i64;
307 let rem = unix_secs % 86_400;
308 let z = days + 719_468;
310 let era = z.div_euclid(146_097);
311 let doe = z.rem_euclid(146_097);
312 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
313 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
314 let mp = (5 * doy + 2) / 153;
315 let day = doy - (153 * mp + 2) / 5 + 1;
316 let month = if mp < 10 { mp + 3 } else { mp - 9 };
317 let year = yoe + era * 400 + i64::from(month <= 2);
318 format!(
319 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
320 rem / 3600,
321 rem / 60 % 60,
322 rem % 60
323 )
324}
325
326pub fn book_from_json(s: &str) -> Result<Book, ProjectError> {
328 let v: Value = serde_json::from_str(s)?;
329 Ok(serde_json::from_value(migrate(v)?)?)
330}
331
332pub type Migration = fn(Value) -> Result<Value, ProjectError>;
338
339pub const MIGRATIONS: &[Migration] = &[migrate_v1_to_v2];
340
341fn migrate_v1_to_v2(v: Value) -> Result<Value, ProjectError> {
347 Ok(v)
348}
349
350const _: () = assert!(MIGRATIONS.len() + 1 == FORMAT_VERSION as usize);
351
352pub fn migrate(v: Value) -> Result<Value, ProjectError> {
355 migrate_with(v, MIGRATIONS)
356}
357
358fn migrate_with(mut v: Value, steps: &[Migration]) -> Result<Value, ProjectError> {
359 let current = steps.len() as u64 + 1;
360 let version = v
361 .get("format_version")
362 .and_then(Value::as_u64)
363 .ok_or(ProjectError::MissingVersion)?;
364 if version == 0 {
365 return Err(ProjectError::MissingVersion);
366 }
367 if version > current {
368 return Err(ProjectError::TooNew { found: version });
369 }
370 for step in steps.iter().skip((version - 1) as usize) {
371 v = step(v)?;
372 }
373 match v.as_object_mut() {
374 Some(obj) => {
375 obj.insert("format_version".into(), Value::from(current));
376 Ok(v)
377 }
378 None => Err(ProjectError::MissingVersion),
379 }
380}
381
382pub fn book_to_json(book: &Book) -> Result<String, ProjectError> {
383 Ok(serde_json::to_string_pretty(book)?)
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::asset::testdata::png_3x2_300dpi;
390 use crate::{Block, Metadata, Section};
391 use std::io::Cursor;
392
393 fn book() -> Book {
394 let mut b = Book::new(Metadata {
395 title: "T".into(),
396 ..Default::default()
397 });
398 b.sections.push(Section::chapter(
399 "Eins",
400 vec![Block::image("map.png", "Karte", None)],
401 ));
402 b
403 }
404
405 fn project() -> Project {
406 let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>"#;
407 Project::new(
408 book(),
409 vec![
410 Asset::new("map.png", png_3x2_300dpi()),
411 Asset::new("ornament.svg", svg.to_vec()),
412 ],
413 )
414 }
415
416 fn to_bytes(p: &Project) -> Vec<u8> {
417 p.write(Cursor::new(Vec::new())).unwrap().into_inner()
418 }
419
420 fn zip_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
421 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
422 for (name, bytes) in entries {
423 zip.start_file(*name, SimpleFileOptions::default()).unwrap();
424 zip.write_all(bytes).unwrap();
425 }
426 zip.finish().unwrap().into_inner()
427 }
428
429 #[test]
430 fn round_trip() {
431 let b = book();
432 let s = book_to_json(&b).unwrap();
433 assert_eq!(book_from_json(&s).unwrap(), b);
434 }
435
436 #[test]
437 fn rejects_newer() {
438 let err = book_from_json(r#"{"format_version": 999}"#).unwrap_err();
439 assert!(matches!(err, ProjectError::TooNew { found: 999 }));
440 let err = book_from_json(r#"{"format_version": 3}"#).unwrap_err();
441 assert!(matches!(err, ProjectError::TooNew { found: 3 }));
442 }
443
444 #[test]
445 fn version_3_project_is_refused() {
446 let book = book_to_json(&book())
447 .unwrap()
448 .replace("\"format_version\": 2", "\"format_version\": 3");
449 for meta_version in [2, 3] {
450 let meta = format!(r#"{{"format_version":{meta_version},"created_with":"x"}}"#);
451 let bytes = zip_of(&[
452 ("meta.json", meta.as_bytes()),
453 ("book.json", book.as_bytes()),
454 ]);
455 assert!(matches!(
456 Project::read(Cursor::new(bytes)),
457 Err(ProjectError::TooNew { found: 3 })
458 ));
459 }
460 }
461
462 #[test]
463 fn version_1_book_saves_as_version_2() {
464 let v1 = book_to_json(&book())
465 .unwrap()
466 .replace("\"format_version\": 2", "\"format_version\": 1");
467 let b = book_from_json(&v1).unwrap();
468 assert_eq!(b.format_version, 2);
469 let again = book_to_json(&b).unwrap();
470 assert!(again.contains("\"format_version\": 2"));
471 }
472
473 #[test]
474 fn migrations_run_in_order_from_the_file_version() {
475 fn v1_to_v2(mut v: Value) -> Result<Value, ProjectError> {
476 v["steps"].as_array_mut().unwrap().push("1→2".into());
477 Ok(v)
478 }
479 fn v2_to_v3(mut v: Value) -> Result<Value, ProjectError> {
480 v["steps"].as_array_mut().unwrap().push("2→3".into());
481 Ok(v)
482 }
483 let steps: &[Migration] = &[v1_to_v2, v2_to_v3];
484 let run = |version: u64| {
485 migrate_with(
486 serde_json::json!({ "format_version": version, "steps": [] }),
487 steps,
488 )
489 };
490 let v = run(1).unwrap();
491 assert_eq!(v["format_version"], 3);
492 assert_eq!(v["steps"], serde_json::json!(["1→2", "2→3"]));
493 assert_eq!(run(2).unwrap()["steps"], serde_json::json!(["2→3"]));
494 assert_eq!(run(3).unwrap()["steps"], serde_json::json!([]));
495 assert!(matches!(run(4), Err(ProjectError::TooNew { found: 4 })));
496 assert!(matches!(run(0), Err(ProjectError::MissingVersion)));
497 }
498
499 #[test]
500 fn bad_versions() {
501 for json in [
502 r#"{}"#,
503 r#"[]"#,
504 r#"{"format_version": "1"}"#,
505 r#"{"format_version": -1}"#,
506 ] {
507 assert!(
508 matches!(book_from_json(json), Err(ProjectError::MissingVersion)),
509 "{json}"
510 );
511 }
512 }
513
514 #[test]
515 fn archive_round_trip() {
516 let p = project();
517 let bytes = to_bytes(&p);
518 let back = Project::read(Cursor::new(&bytes)).unwrap();
519 assert_eq!(back, p);
520 assert_eq!(
521 back.asset("map.png").unwrap().dpi.map(f32::round),
522 Some(300.0)
523 );
524 assert_eq!(to_bytes(&back), bytes);
526 }
527
528 #[test]
529 fn archive_layout() {
530 let bytes = to_bytes(&project());
531 let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
532 let names: Vec<_> = zip.file_names().map(|n| n.unwrap().into_owned()).collect();
533 assert_eq!(
534 names,
535 [
536 "meta.json",
537 "book.json",
538 "assets/map.png",
539 "assets/ornament.svg"
540 ]
541 );
542 let png = zip.by_name("assets/map.png").unwrap();
543 assert_eq!(png.compression(), CompressionMethod::Stored);
544 drop(png);
545 let mut meta = String::new();
546 zip.by_name("meta.json")
547 .unwrap()
548 .read_to_string(&mut meta)
549 .unwrap();
550 let meta: Value = serde_json::from_str(&meta).unwrap();
551 assert_eq!(meta["format_version"], 2);
552 assert_eq!(meta["created_with"], CREATED_WITH);
553 }
554
555 #[test]
556 fn lenient_read() {
557 let book = book_to_json(&book()).unwrap();
558 let png = png_3x2_300dpi();
559 let bytes = zip_of(&[
560 ("book.json", book.as_bytes()),
561 ("assets/.DS_Store", b"junk"),
562 ("assets/sub/x.png", &png),
563 ("__MACOSX/assets/._map.png", b"junk"),
564 ("assets/map.png", &png),
565 ("thumbnails/cover.png", &png),
566 ]);
567 let p = Project::read(Cursor::new(bytes)).unwrap();
568 assert_eq!(p.meta.created_with, "unknown");
569 let ids: Vec<_> = p.assets.iter().map(|a| a.id.as_str()).collect();
570 assert_eq!(ids, ["map.png"]);
571 assert_eq!(p.assets[0].width_px, Some(3));
572 }
573
574 #[test]
575 fn unknown_meta_fields_survive() {
576 let book = book_to_json(&book()).unwrap();
577 let meta = br#"{"format_version":2,"created_with":"kernsetzer 9","modified":"2030-01-01T00:00:00Z","window":{"zoom":2}}"#;
578 let bytes = zip_of(&[("meta.json", meta), ("book.json", book.as_bytes())]);
579 let p = Project::read(Cursor::new(bytes)).unwrap();
580 assert_eq!(p.meta.created_with, "kernsetzer 9");
581 assert_eq!(p.meta.extra["window"]["zoom"], 2);
582 let again = Project::read(Cursor::new(to_bytes(&p))).unwrap();
583 assert_eq!(again.meta, p.meta);
584 }
585
586 #[test]
587 fn read_errors() {
588 assert!(matches!(
589 Project::read(Cursor::new(b"not a zip".to_vec())),
590 Err(ProjectError::Zip(_))
591 ));
592 let bytes = zip_of(&[("assets/map.png", &png_3x2_300dpi())]);
593 assert!(matches!(
594 Project::read(Cursor::new(bytes)),
595 Err(ProjectError::NotAProject)
596 ));
597 let book = book_to_json(&book()).unwrap();
598 let bytes = zip_of(&[
599 ("meta.json", br#"{"format_version":7,"created_with":"x"}"#),
600 ("book.json", book.as_bytes()),
601 ]);
602 assert!(matches!(
603 Project::read(Cursor::new(bytes)),
604 Err(ProjectError::TooNew { found: 7 })
605 ));
606 let full = to_bytes(&project());
608 for n in (0..full.len()).step_by(7) {
609 assert!(Project::read(Cursor::new(&full[..n])).is_err(), "{n}");
610 }
611 }
612
613 #[test]
614 fn damaged_archives_never_panic() {
615 let full = to_bytes(&project());
616 for i in 0..full.len() {
617 for mask in [0x01, 0x80, 0xFF] {
618 let mut bytes = full.clone();
619 bytes[i] ^= mask;
620 let _ = Project::read(Cursor::new(bytes));
621 }
622 }
623 }
624
625 #[test]
626 fn write_rejects_bad_ids() {
627 let mut p = project();
628 p.assets.push(Asset::new("../evil.png", png_3x2_300dpi()));
629 assert!(matches!(
630 p.write(Cursor::new(Vec::new())),
631 Err(ProjectError::InvalidAssetId(_))
632 ));
633 let mut p = project();
634 p.assets.push(Asset::new("map.png", png_3x2_300dpi()));
635 assert!(matches!(
636 p.write(Cursor::new(Vec::new())),
637 Err(ProjectError::DuplicateAsset(_))
638 ));
639 }
640
641 #[test]
642 fn save_open_and_autosave() {
643 let dir = std::env::temp_dir().join(format!("setzer-model-{}", std::process::id()));
644 fs::create_dir_all(&dir).unwrap();
645 let path = dir.join("Roman.kernsetzer");
646 let sidecar = autosave_path(&path);
647 assert_eq!(sidecar, dir.join("Roman.kernsetzer.autosave"));
648
649 let mut p = project();
650 p.autosave(&path).unwrap();
651 assert!(sidecar.exists() && !path.exists());
652 assert_eq!(Project::open(&sidecar).unwrap().book, p.book);
653
654 p.save(&path).unwrap();
655 assert!(path.exists() && !sidecar.exists());
656 assert!(!with_suffix(&path, ".saving").exists());
657 let modified = p.meta.modified.clone().unwrap();
658 assert!(
659 modified.ends_with('Z') && modified.len() == 20,
660 "{modified}"
661 );
662 assert_eq!(Project::open(&path).unwrap(), p);
663
664 p.book.metadata.title = "Zweiter Titel".into();
666 p.save(&path).unwrap();
667 assert_eq!(
668 Project::open(&path).unwrap().book.metadata.title,
669 "Zweiter Titel"
670 );
671 fs::remove_dir_all(&dir).unwrap();
672 }
673
674 #[test]
675 fn timestamps() {
676 assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
677 assert_eq!(rfc3339(951_782_400), "2000-02-29T00:00:00Z");
678 assert_eq!(rfc3339(1_792_368_000 + 3_723), "2026-10-19T01:02:03Z");
679 assert_eq!(rfc3339(4_102_444_799), "2099-12-31T23:59:59Z");
680 }
681}