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