//! Field packages (#1902): a map taken to a phone and brought back. //! //! A field package (`.rgisfield`) is a zip holding a project of one map, ` (offline)`, and //! the files it reads beside it: the MBTiles and PMTiles archives its tile layers draw from, under //! `basemaps/`, so the map has its basemap with no network. Packaging the active map makes an //! offline area of it in this project first (as Download Map does), so the edits made in the //! field can be brought back to it by feature, with the offline area's conflict handling. //! //! Opening a field package unpacks it into the app's own folder (`field/`) and opens the //! project there, unsaved, as a project package opens. //! //! Coming back, the field's map is packaged as it is, with the photos its features name (in a //! layer's `photos` field, paths separated by `;`) under `photos/`. Bring Back Edits //! (`offline.bringBack`) reads such a package in the project it was made from, puts its photos in //! a folder beside that project, and copies the edits back to the area's map by feature, as Sync //! does, saying which features were changed on both sides. use std::path::{Path, PathBuf}; use serde_json::{Value, json}; use crate::Session; use crate::project::Project; /// A field package's extension. pub const EXTENSION: &str = "rgisfield"; /// The folder of a field package that holds its tile archives. const BASEMAPS: &str = "basemaps"; /// The folder of a field package that holds its features' photos. const PHOTOS: &str = "photos"; /// What an offline area's map is called after: ` (offline)`. pub(crate) const OFFLINE: &str = " (offline)"; /// The most bytes read from one field package's entries when it is opened. const MOST_UNPACKED: usize = rustgis_data::zip::MAX_TOTAL; /// Whether a file name or path is a field package's, ignoring case. pub fn is_field_package(name: &str) -> bool { name.to_ascii_lowercase().strip_suffix(EXTENSION).is_some_and(|s| s.ends_with('.')) } /// A field package of the active map, as (name, bytes). /// /// An offline area's map (` (offline)` of one of this project's areas, or the map of a /// project opened from a field package) is packaged as it is, to bring its edits back. Any other map is first made an offline area of this project (`extent` /// and `name` as for `offline.download`, the view by default), whose map is packaged. /// /// Never silent: the raster layers an offline area leaves behind, the tile layers that stay /// online (a web address, not an archive), and an archive that couldn't be read are said. /// /// It runs where it is called, on the window's thread from Save (`ui.saveItem`), as Package /// Project does: each archive is one read and the zip one write, with no Cancel between them. pub(crate) fn package(s: &mut Session, p: &Value) -> Result<(String, Vec), String> { // An offline area's map: named for one of this project's areas, or opened from a field // package (the phone's project holds no areas). A map only named so is packaged as any other. let opened_field = s.opened_package.as_deref().is_some_and(|p| is_field_package(&p.to_string_lossy())); let returning = s .map() .name .strip_suffix(OFFLINE) .map(str::to_string) .filter(|area| opened_field || s.project.offline_areas.iter().any(|a| a.name.eq_ignore_ascii_case(area))); // Only a map coming back carries its photos: going out, the photos named are at home. let coming_back = returning.is_some(); let (area, mut map) = match returning { Some(area) => { // Its raster layers stay behind, as an offline area's do going out: said. let mut m = s.map().clone(); let rasters: Vec = m.layers.iter().filter(|l| l.is_raster()).map(|l| l.id).collect(); if !rasters.is_empty() { m.release_tiles(&rasters); m.layers.retain(|l| !l.is_raster()); s.notify(format!("{} raster layer(s) were left out of the field package: it holds feature layers", rasters.len()), false); } (area, m) } None => { let rasters = s.map().layers.iter().filter(|l| l.is_raster()).count(); let made = super::workspace::offline_download(s, p)?; let name = made.get("name").and_then(Value::as_str).ok_or("the offline area wasn't made")?.to_string(); let data = s.project.offline_areas.iter().find(|a| a.name == name).map(|a| a.data.clone()).ok_or("the offline area wasn't made")?; if rasters > 0 { s.notify( format!("{rasters} raster layer(s) were left out of the field package: an offline area holds feature layers"), false, ); } s.notify( format!("Made the offline area '{name}' of this map for the field package: save this project to bring the field's edits back to it later"), false, ); let mut m = data; m.name = format!("{name}{OFFLINE}"); (name, m) } }; let mut entries: Vec<(String, Vec)> = Vec::new(); let mut budget = MOST_UNPACKED as u64 / 2; // Each local archive goes in beside the project, its layer reading it from there. let (mut online, mut unread, mut uploaded) = (Vec::new(), Vec::new(), Vec::new()); let mut taken = std::collections::HashSet::new(); for t in &mut map.tiles { let Some(file) = t.archive_file().map(str::to_string) else { // An archive added to the web app lives in its memory, not in a file to put in. if t.url.starts_with(rustgis_map::tiles::UPLOAD) { uploaded.push(t.name.clone()); } else { online.push(t.name.clone()); } continue; }; match crate::project::read_file(Path::new(&file), budget) { Ok(bytes) => { budget = budget.saturating_sub(bytes.len() as u64); // A name the package's folders can hold as one file, the same in the entry and the // layer's address: `:` and `\` (fine in a Linux name) would be refused or split. let stem: String = Path::new(&file).file_name().map_or_else( || "basemap".into(), |n| n.to_string_lossy().chars().map(|c| if matches!(c, ':' | '\\' | '/') { '_' } else { c }).collect(), ); let mut name = stem.clone(); for n in 2.. { if taken.insert(name.to_ascii_lowercase()) { break; } name = format!("{n}-{stem}"); } t.url = format!("{BASEMAPS}/{name}"); entries.push((t.url.clone(), bytes)); } Err(e) => unread.push(format!("{} ({e})", t.name)), } } if !online.is_empty() { s.notify( format!( "{} tile layer(s) in the field package draw from the web and show only online: {}. Add an MBTiles or PMTiles archive for a basemap with no network", online.len(), online.join(", ") ), false, ); } if !uploaded.is_empty() { s.notify( format!( "{} tile archive(s) added to this browser couldn't be put in the field package, as they live only in its memory: {}. Add them to the field from the file", uploaded.len(), uploaded.join(", ") ), true, ); } if !unread.is_empty() { s.notify(format!("{} tile archive(s) couldn't be put in the field package: {}", unread.len(), unread.join(", ")), true); } // Coming back, the photos taken in the field go with their features. if coming_back { let photos = take_photos(&mut map, &mut budget); if !photos.unread.is_empty() { let unread = &photos.unread; s.notify(format!("{} photo(s) couldn't be put in the field package: {}", unread.len(), unread.join(", ")), true); } if !photos.not_pictures.is_empty() { let named = &photos.not_pictures; s.notify( format!( "{} file(s) a photos field names weren't put in the field package: only pictures go ({}): {}", named.len(), PICTURES.join(", "), named.join(", ") ), false, ); } entries.extend(photos.entries); } // A project of the one map: what else the project holds (tables, notebooks, models, shared // items and their paths…) stays at home. The custom colour ramps and styles go with it, as its // layers may draw with them. let mut project = crate::project::Project::new(area.clone()); project.maps = vec![super::items::embedded_map(&map, &mut budget)]; project.ramps = s.project.ramps.clone(); project.styles = s.project.styles.clone(); let readme = format!( "{area}: a RustGIS field package.\nOpen it with RustGIS on a phone or a computer (Open Project). Its basemaps are in {BASEMAPS}/.\n" ); let room = crate::project::max_bytes().min(MOST_UNPACKED as u64).saturating_sub(entries.iter().map(|(_, b)| b.len() as u64).sum()); let too_large = || "the field package would be too large: leave a tile archive or some data out".to_string(); let (text, report) = crate::durable::output_owned(project, |p| crate::project::json_within(p, room)?.ok_or_else(too_large))?; crate::durable::notify(s, &report); entries.insert(0, (format!("{}.{}", super::items::safe(&area), crate::project::EXTENSION), text.into_bytes())); entries.push(("README.txt".into(), readme.into_bytes())); Ok((area, rustgis_data::zip::write(&entries)?)) } /// Whether `map` is an offline area's map (` (offline)`), packaged to come back. fn returning_map(map: &rustgis_map::Map) -> bool { map.name.ends_with(OFFLINE) } /// A layer's photos field, by its place: its text field named `photos` or `photo`, whatever the /// case (as the touch interface names it). fn photos_field(layer: &rustgis_map::Layer) -> Option { layer.dataset.fields.iter().position(|f| { f.ty == rustgis_data::FieldType::Text && (f.name.eq_ignore_ascii_case("photos") || f.name.eq_ignore_ascii_case("photo")) }) } /// Rewrites each photos field of `map` with `rewrite` (one path in, the path to name instead out). fn rewrite_photos(map: &mut rustgis_map::Map, mut rewrite: impl FnMut(&str) -> String) { for l in map.layers.iter_mut().filter(|l| !l.is_raster()) { let Some(fi) = photos_field(l) else { continue }; for f in &mut l.dataset.features { let Some(rustgis_data::Value::Text(held)) = f.attrs.get(fi) else { continue }; // Only the paths rewritten change: the rest of the text stays as it was written, so // a feature isn't seen as edited for its spacing. let (mut out, mut last) = (String::new(), 0); for r in photo_paths(held) { let (Some(before), Some(path)) = (held.get(last..r.start), held.get(r.clone())) else { continue }; let new = rewrite(path); if new != path { out.push_str(before); out.push_str(&new); last = r.end; } } if last == 0 { continue; } out.push_str(held.get(last..).unwrap_or_default()); if let Some(slot) = f.attrs.get_mut(fi) { *slot = rustgis_data::Value::Text(out); } } } } /// Where the paths a photos field holds are in its text (`;` between them), trimmed, empty ones /// left out. A path with a `;` of its own is one path when, joined again, it names a file on this /// device. fn photo_paths(held: &str) -> Vec> { let mut pieces = Vec::new(); let mut start = 0; for (i, c) in held.char_indices() { if c == ';' { pieces.push(start..i); start = i + 1; } } pieces.push(start..held.len()); let names_file = |r: &std::ops::Range| held.get(r.clone()).is_some_and(|p| Path::new(p.trim()).is_file()); let mut out = Vec::new(); let mut i = 0; while let Some(first) = pieces.get(i) { // The longest run of pieces from here that names a file, or this one alone. let last = (i + 1..pieces.len()).rev().find(|j| pieces.get(*j).is_some_and(|end| names_file(&(first.start..end.end)))).unwrap_or(i); let end = pieces.get(last).map_or(first.end, |p| p.end); if let Some(text) = held.get(first.start..end) { let lead = text.len() - text.trim_start().len(); let trimmed = text.trim(); if !trimmed.is_empty() { out.push(first.start + lead..first.start + lead + trimmed.len()); } } i = last + 1; } out } /// The photo files `map`'s features name that are on this device, as package entries under /// `photos/` (within `budget`), each feature then naming its photos there; and the photos named /// that couldn't be read. Only picture files ([`PICTURES`]) named by an absolute path go in, each /// under a plain name ([`plain_name`]): a photos field is free text, and could name any file. A /// photo named that isn't on this device (one named at home) is left as it is named. fn take_photos(map: &mut rustgis_map::Map, budget: &mut u64) -> Photos { let (mut entries, mut unread, mut not_pictures) = (Vec::new(), Vec::new(), Vec::new()); // Each photo's entry and the path it was named by, for the way back. let mut index = String::new(); let mut placed: std::collections::HashMap = std::collections::HashMap::new(); let mut taken = std::collections::HashSet::new(); // The files already said (not pictures, or unread): each said once, however many name it. let mut said = std::collections::HashSet::new(); rewrite_photos(map, |path| { if let Some(entry) = placed.get(path) { return entry.clone(); } if said.contains(path) { return path.to_string(); } let file = Path::new(path); // A picture only: a photos field is free text, and could name any file on the device. let picture = file.extension().is_some_and(|e| PICTURES.iter().any(|p| e.eq_ignore_ascii_case(p))); if !file.is_absolute() || !file.is_file() { return path.to_string(); } if !picture { said.insert(path.to_string()); not_pictures.push(path.to_string()); return path.to_string(); } match crate::project::read_file(file, *budget) { Ok(bytes) => { *budget = budget.saturating_sub(bytes.len() as u64); let stem = file.file_name().map(|n| plain_name(&n.to_string_lossy())).unwrap_or_else(|| "photo.jpg".into()); let mut name = stem.clone(); for n in 2.. { if taken.insert(name.to_ascii_lowercase()) { break; } name = format!("{n}-{stem}"); } let entry = format!("{PHOTOS}/{name}"); index.push_str(&format!("{entry}\t{path}\n")); entries.push((entry.clone(), bytes)); placed.insert(path.to_string(), entry.clone()); entry } Err(e) => { said.insert(path.to_string()); unread.push(format!("{path} ({e})")); path.to_string() } } }); if !index.is_empty() { entries.push((INDEX.to_string(), index.into_bytes())); } Photos { entries, unread, not_pictures } } /// What [`take_photos`] found: the package's photo entries (with [`INDEX`]), the photos that /// couldn't be read, and the files named that aren't pictures. struct Photos { entries: Vec<(String, Vec)>, unread: Vec, not_pictures: Vec, } /// The entry listing each photo of a package and the path its feature named it by before /// (`photos/a.jpg/storage/…/a.jpg`), so a photo brought back to where it already is keeps /// that path. const INDEX: &str = "photos/index.tsv"; /// The extensions of the picture files a returning field package carries. const PICTURES: &[&str] = &["jpg", "jpeg", "png", "heic", "heif", "webp"]; /// A file name with nothing a package's photo entry may not hold: no folder separator or drive /// colon, and not hidden (a leading dot). fn plain_name(name: &str) -> String { let name: String = name.chars().map(|c| if c.is_control() || FORBIDDEN.contains(c) { '_' } else { c }).collect(); let name = name.trim_end_matches(['.', ' ']).to_string(); match name.strip_prefix('.') { Some(rest) => format!("_{rest}"), None if device_name(&name) => format!("_{name}"), None if name.is_empty() => "photo".into(), None => name, } } /// What a file name can't hold on one of the systems a package goes to (Windows forbids the most). const FORBIDDEN: &str = "/\\:*?\"<>|"; /// Whether a file name is a Windows device's (`CON`, `NUL.jpg`, `COM1`…), which can't be a file. fn device_name(name: &str) -> bool { let stem = name.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase(); matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || ((stem.starts_with("COM") || stem.starts_with("LPT")) && stem.len() == 4 && stem.ends_with(|c: char| c.is_ascii_digit())) } /// Whether a photo entry's name is a plain file name on every system: nothing that would put it /// outside its folder or that a system refuses, so no photo is half written before another fails. fn plain_file(file: &str) -> bool { !file.is_empty() && file == plain_name(file) } /// Brings a field package's edits back (`offline.bringBack`): the package (`path`, or `data` in /// base64) holds an offline area's map, which must be an area of this project. Its photos go in a /// folder beside this project (` photos`), or the app's own `field photos/` for a /// project not saved yet, and its features name them there; one whose name is taken there by /// other bytes gets a name of its own (`2-`), and an entry that isn't a plain file name is /// left out and counted. Then its edits are copied back to the area's map by feature, as Sync does /// (`sync_area`), which says the conflicts. /// /// It runs where it is called, on the window's thread, as Sync does: the package is one read, each /// photo one write, and the sync one pass over the area's features, with no Cancel between them. pub(crate) fn bring_back(s: &mut Session, p: &Value) -> Result { let bytes = match super::ps(p, "path") { Some(path) => crate::project::read_file(Path::new(path), MOST_UNPACKED as u64)?, None => { use base64::Engine; let data = super::ps(p, "data").ok_or("path or data is required")?; base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|_| "data must be base64".to_string())? } }; let entries = rustgis_data::zip::read(&bytes)?; let mut text = None; let mut photos = Vec::new(); // Photo entries that aren't a plain file name, left out. let mut strange = 0usize; let mut index = String::new(); for (name, data) in entries { if name == INDEX { index = String::from_utf8_lossy(&data).into_owned(); } else if !name.contains('/') && crate::project::is_project_file(&name) { text = Some(String::from_utf8(data).map_err(|_| "the field package's project is damaged".to_string())?); } else if let Some(file) = name.strip_prefix(&format!("{PHOTOS}/")) { // A photo's own name only: nothing that would put it outside its folder. if plain_file(file) { photos.push((file.to_string(), data)); } else { strange += 1; } } } let text = text.ok_or("the file holds no field package's project")?; let project = Project::from_import_json(&text, None)?; let mut edited = project .maps .into_iter() .find(returning_map) .ok_or("the field package holds no offline area's map: package the map in the field (Send Field Package) to bring it back")?; let area = edited.name.strip_suffix(OFFLINE).unwrap_or_default().to_string(); let ai = s.project.offline_areas.iter().position(|a| a.name.eq_ignore_ascii_case(&area)).ok_or_else(|| { format!( "this project has no offline area '{area}': open the project the field package was made from, as it was saved after packaging" ) })?; // The photos, beside the project, and the features naming them there. let mut put = 0usize; let mut renamed = 0usize; let mut folder = None; if !photos.is_empty() { let dir = match s.path.as_deref().and_then(Path::parent) { Some(home) => home.join(format!("{} photos", super::items::safe(&area))), None => rustgis_data::dirs::app_data_dir() .ok_or("this computer gives RustGIS no folder for the field package's photos: save the project first")? .join("field photos") .join(super::items::safe(&area)), }; std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; let mut written = std::collections::HashMap::new(); // The path each photo was named by where it was packaged. let before: std::collections::HashMap<&str, &str> = index.lines().filter_map(|l| l.split_once('\t')).collect(); for (name, data) in photos { let entry = format!("{PHOTOS}/{name}"); // Packaged from here (the same file, the same bytes): it keeps its own path. if let Some(own) = before.get(entry.as_str()).filter(|p| std::fs::read(p).is_ok_and(|there| there == data)) { written.insert(entry, own.to_string()); continue; } // A file of that name already there (an earlier package's) is kept: the same bytes // are that photo, other bytes get a name of their own. let mut path = dir.join(&name); for n in 2.. { match std::fs::read(&path) { Ok(there) if there == data => break, Ok(_) => path = dir.join(format!("{n}-{name}")), Err(_) => break, } } if path.file_name().is_some_and(|f| f.to_string_lossy() != name) { renamed += 1; } std::fs::write(&path, data).map_err(|e| format!("{}: {e}", path.display()))?; written.insert(format!("{PHOTOS}/{name}"), path.display().to_string()); put += 1; } rewrite_photos(&mut edited, |path| written.get(path).cloned().unwrap_or_else(|| path.to_string())); folder = Some(dir.display().to_string()); } // Fields the field added (a layer's photos field, made with its first photo) come too. let added = take_new_fields(s, ai, &edited); if !added.is_empty() { s.notify(format!("Added the field(s) the field package's layers added: {}", added.join(", ")), false); } let mut done = super::workspace::sync_area(s, ai, &edited)?; let said = |k: &str| done.get(k).and_then(Value::as_u64).unwrap_or(0); let mut summary = format!( "Brought the field's edits back to offline area '{area}': {} feature(s) changed, {} added, {} deleted", said("updated"), said("added"), said("deleted") ); if let Some(dir) = &folder { summary.push_str(&format!("; {put} photo(s) put in {dir}")); if renamed > 0 { summary.push_str(&format!(" ({renamed} under a new name: one of its name was there already)")); } } if strange > 0 { s.notify(format!("{strange} photo(s) in the field package weren't brought back: their names aren't plain file names"), true); } s.notify(summary, false); if let Some(o) = done.as_object_mut() { o.insert("area".into(), json!(area)); o.insert("photos".into(), json!(put)); o.insert("photoFolder".into(), json!(folder)); } Ok(done) } /// Adds to offline area `ai`'s source layers, and to the area's own copy of them (so features /// aren't all taken as edited for lacking it), the fields `edited`'s layers have after theirs: a /// field the field added. Only fields added after all of the layer's own, of names it hasn't; /// fields that differ otherwise are left for the sync to refuse. Returns them as `layer: field`. fn take_new_fields(s: &mut Session, ai: usize, edited: &rustgis_map::Map) -> Vec { let Some(area) = s.project.offline_areas.get(ai) else { return Vec::new() }; let source = area.map.clone(); let same = |a: &[rustgis_data::Field], b: &[rustgis_data::Field]| { a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.name.eq_ignore_ascii_case(&y.name) && x.ty == y.ty) }; let mut added = Vec::new(); for ol in edited.layers.iter().filter(|l| !l.is_raster()) { let fields = &ol.dataset.fields; let Some(si) = s.project.maps.iter().position(|m| m.name.eq_ignore_ascii_case(&source)) else { break }; let Some(sl) = s.project.maps.get(si).and_then(|m| m.layers.iter().find(|l| l.id == ol.id && !l.is_raster())) else { continue }; let had = sl.dataset.fields.len(); let base_same = s .project .offline_areas .get(ai) .and_then(|a| a.data.layers.iter().find(|l| l.id == ol.id)) .is_none_or(|b| same(&b.dataset.fields, &sl.dataset.fields)); let Some(extra) = fields.get(had..).filter(|e| !e.is_empty()) else { continue }; if !base_same || !fields.get(..had).is_some_and(|own| same(own, &sl.dataset.fields)) { continue; } if extra.iter().any(|f| sl.dataset.field_index(&f.name).is_some()) { continue; } let name = sl.name.clone(); if let Some(l) = s.project.maps.get_mut(si).and_then(|m| m.layers.iter_mut().find(|l| l.id == ol.id)) { for f in extra { l.dataset.ensure_field(&f.name, f.ty); } } if let Some(b) = s.project.offline_areas.get_mut(ai).and_then(|a| a.data.layers.iter_mut().find(|l| l.id == ol.id)) { for f in extra { b.dataset.ensure_field(&f.name, f.ty); } } added.extend(extra.iter().map(|f| format!("{name}: {}", f.name))); } added } /// The folder a field package named `stem` is unpacked into: the app's own `field/`. fn unpack_dir(stem: &str) -> Result { let base = rustgis_data::dirs::app_data_dir().ok_or("this computer gives RustGIS no folder of its own to unpack the field package into")?; Ok(fresh_dir(&base.join("field"), &super::items::safe(stem))) } /// A folder in `base` named `name`, or `name 2`, `name 3`… when one is there: a copy unpacked /// before (another package of the same name, perhaps still open) is never written over. fn fresh_dir(base: &Path, name: &str) -> PathBuf { let mut dir = base.join(name); for n in 2.. { if !dir.exists() { break; } dir = base.join(format!("{name} {n}")); } dir } /// Unpacks a field package's `bytes` into a folder of its own in the app's folder (`field/`, /// numbered when that is taken) and returns the folder and the project's text. An entry that /// would land outside the folder is refused, and the package with it. /// /// The project's own paths are trusted as a project file's are: a package from someone else can /// name files outside the folder, as a `.rgis` can. pub(crate) fn unpack(bytes: &[u8], stem: &str) -> Result<(PathBuf, String), String> { let dir = unpack_dir(stem)?; let text = unpack_into(bytes, &dir)?; Ok((dir, text)) } /// Unpacks a field package's `bytes` into `dir`, a folder not there yet (see [`unpack`]), and /// returns the project's text. A write that fails removes what was written. fn unpack_into(bytes: &[u8], dir: &Path) -> Result { let entries = rustgis_data::zip::read(bytes)?; let mut project = None; let mut files = Vec::new(); for (name, data) in entries { let parts: Vec<&str> = name.split(['/', '\\']).filter(|p| !p.is_empty()).collect(); if parts.is_empty() || parts.iter().any(|p| *p == ".." || *p == "." || p.contains(':')) || name.starts_with(['/', '\\']) { return Err(format!("the field package holds an entry outside its folder: {name}")); } if parts.len() == 1 && crate::project::is_project_file(&name) { if project.is_some() { return Err("the field package holds more than one project".into()); } project = Some(String::from_utf8(data).map_err(|_| "the field package's project is damaged".to_string())?); } else { files.push((parts.iter().collect::(), data)); } } let text = project.ok_or("the field package holds no project")?; // Read before anything is written: a project that can't be read leaves no folder behind. Project::from_import_json(&text, Some(dir))?; if dir.exists() { return Err(format!("{}: the folder is already there", dir.display())); } let written = (|| { for (rel, data) in files { let path = dir.join(rel); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; } std::fs::write(&path, data).map_err(|e| format!("{}: {e}", path.display()))?; } Ok::<(), String>(()) })(); if let Err(e) = written { let _ = std::fs::remove_dir_all(dir); return Err(e); } std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; Ok(text) } /// Reads a field package's project, its relative paths resolved against `dir`. pub(crate) fn project_in(text: &str, dir: &Path) -> Result { Project::from_import_json(text, Some(dir)) } /// Removes the offline areas past the first `had` of this project: those a field package just made, /// when it wasn't saved after all (the save was cancelled or failed). Says so. pub fn forget_new_areas(s: &mut Session, had: usize) { let made: Vec = s.project.offline_areas.iter().skip(had).map(|a| a.name.clone()).collect(); for name in made { s.project.offline_areas.retain(|a| a.name != name); s.notify(format!("The field package wasn't saved, so its offline area '{name}' was removed too"), false); } } /// What `offline.package` returns. pub(crate) fn packaged(path: &str, area: &str, bytes: usize) -> Value { json!({"path": path, "area": area, "bytes": bytes}) } #[cfg(test)] mod tests { use super::*; use rustgis_map::tiles::{TileKind, TileLayer}; /// A folder of the test's own, removed when it ends. struct Temp(PathBuf); impl Temp { fn new(name: &str) -> Temp { let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); let dir = std::env::temp_dir().join(format!("rustgis-field-{name}-{}-{nanos}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); Temp(dir) } fn path(&self) -> &Path { &self.0 } } impl Drop for Temp { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn sample() -> Session { Session::new(crate::sample::world_project()) } #[test] fn a_map_packaged_for_the_field_opens_with_its_basemap_and_comes_from_an_offline_area() { let temp = Temp::new("package"); let archive = temp.path().join("site.mbtiles"); std::fs::write(&archive, b"stand-in archive bytes").unwrap(); let mut s = sample(); s.map_mut().tiles.push(TileLayer::new("Site", TileKind::MbTiles, archive.display().to_string())); s.map_mut().tiles.push(TileLayer::new("Streets", TileKind::Raster, "https://tiles.example.com/{z}/{x}/{y}.png")); let mark = s.notice_mark(); let (area, bytes) = package(&mut s, &json!({"name": "Survey", "extent": [-20.0, -40.0, 60.0, 40.0]})).unwrap(); assert_eq!(area, "Survey"); // The offline area is in this project, to bring the edits back to. assert!(s.project.offline_areas.iter().any(|a| a.name == "Survey")); // The online tile layer is said; so is the area made. let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("Streets") && t.contains("online")), "{said:?}"); assert!(said.iter().any(|t| t.contains("offline area 'Survey'")), "{said:?}"); // The package: the project, the archive under basemaps/, a README. let entries = rustgis_data::zip::read(&bytes).unwrap(); let names: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(names, ["Survey.rgis", "basemaps/site.mbtiles", "README.txt"]); assert_eq!(entries[1].1, b"stand-in archive bytes"); // Unpacked and read: one map, the area's, its features in it, its basemap read from beside it. let into = temp.path().join("unpacked"); let text = unpack_into(&bytes, &into).unwrap(); let project = project_in(&text, &into).unwrap(); assert_eq!(project.maps.len(), 1); let map = &project.maps[0]; assert_eq!(map.name, "Survey (offline)"); assert!(map.layers.iter().any(|l| !l.dataset.features.is_empty())); let base = map.tiles.iter().find(|t| t.name == "Site").unwrap(); assert_eq!(Path::new(&base.url), into.join("basemaps/site.mbtiles")); assert!(into.join("basemaps/site.mbtiles").is_file()); // Unpacked again: into a folder of its own, the first left as it is. assert!(unpack_into(&bytes, &into).unwrap_err().contains("already there")); let again = fresh_dir(temp.path(), "unpacked"); assert_eq!(again, temp.path().join("unpacked 2")); unpack_into(&bytes, &again).unwrap(); assert!(into.join("basemaps/site.mbtiles").is_file() && again.join("basemaps/site.mbtiles").is_file()); } #[test] fn a_field_package_holds_only_its_map_and_a_basemap_of_any_name_is_read() { let temp = Temp::new("only"); // A name a Linux file can have, which a package's folders couldn't hold as it is. let archive = temp.path().join("site:a\\b.mbtiles"); std::fs::write(&archive, b"stand-in archive bytes").unwrap(); let mut s = sample(); s.map_mut().tiles.push(TileLayer::new("Site", TileKind::MbTiles, archive.display().to_string())); // What else the project holds stays at home. s.project .tables .push(crate::project::Table::new(7, rustgis_data::Dataset::new("Inventory", rustgis_data::GeometryType::None, Vec::new()))); s.project.folders.push("/home/someone/data".into()); let (_, bytes) = package(&mut s, &json!({"name": "Survey", "extent": [-20.0, -40.0, 60.0, 40.0]})).unwrap(); let names: Vec = rustgis_data::zip::read(&bytes).unwrap().into_iter().map(|(n, _)| n).collect(); assert!(names.contains(&"basemaps/site_a_b.mbtiles".to_string()), "{names:?}"); let into = temp.path().join("unpacked"); let project = project_in(&unpack_into(&bytes, &into).unwrap(), &into).unwrap(); assert!(project.tables.is_empty() && project.folders.is_empty() && project.offline_areas.is_empty()); assert_eq!(project.maps.len(), 1); let base = project.maps[0].tiles.iter().find(|t| t.name == "Site").unwrap(); assert_eq!(Path::new(&base.url), into.join("basemaps/site_a_b.mbtiles")); assert!(into.join("basemaps/site_a_b.mbtiles").is_file()); } #[test] fn a_field_package_not_saved_leaves_no_offline_area() { let mut s = sample(); let had = s.project.offline_areas.len(); // A place that can't be written: the command is refused, and the area made for it goes. let e = s.run("offline.package", &json!({"path": "/no/such/folder/site.rgisfield", "name": "Lost"})).unwrap_err(); assert!(e.contains("site.rgisfield"), "{e}"); assert_eq!(s.project.offline_areas.len(), had); // As the front end does when its Save is cancelled: the area goes, and that is said. package(&mut s, &json!({"name": "Cancelled"})).unwrap(); assert_eq!(s.project.offline_areas.len(), had + 1); let mark = s.notice_mark(); forget_new_areas(&mut s, had); assert_eq!(s.project.offline_areas.len(), had); assert!(s.notices_since(mark).iter().any(|n| n.text.contains("'Cancelled' was removed"))); } #[test] fn what_a_package_leaves_behind_is_said_and_a_clean_one_says_nothing() { let temp = Temp::new("notices"); let archive = temp.path().join("base.pmtiles"); std::fs::write(&archive, b"tiles").unwrap(); // Clean: one local archive, no raster, no web layer: no notice of anything left behind. let mut s = sample(); s.map_mut().layers.retain(|l| !l.is_raster()); s.map_mut().tiles.push(TileLayer::new("Base", TileKind::PmTiles, archive.display().to_string())); let mark = s.notice_mark(); package(&mut s, &json!({"name": "Clean"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().all(|t| !t.contains("left out") && !t.contains("from the web") && !t.contains("couldn't be put")), "{said:?}"); // An archive that can't be read, and a raster layer: each said. use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let gt = GeoTransform { origin_x: 10.0, origin_y: 10.0, pixel_width: 1.0, pixel_height: -1.0 }; let r = Raster::new("relief", 2, 2, vec![vec![1.0; 4]], gt, Crs::from_epsg(4326).unwrap(), None, ColorHint::Gray, "Float32").unwrap(); let tif = temp.path().join("relief.tif"); std::fs::write(&tif, geotiff::write(&r, 0).unwrap()).unwrap(); s.run("layer.addData", &json!({"path": tif.display().to_string()})).unwrap(); assert!(s.map().layers.iter().any(|l| l.is_raster())); s.map_mut().tiles.push(TileLayer::new("Gone", TileKind::MbTiles, temp.path().join("gone.mbtiles").display().to_string())); let mark = s.notice_mark(); package(&mut s, &json!({"name": "Lossy"})).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("couldn't be put") && t.contains("Gone")), "{said:?}"); assert!(said.iter().any(|t| t.contains("raster layer(s) were left out")), "{said:?}"); } #[test] fn a_returning_maps_rasters_and_a_browsers_archives_are_said() { let temp = Temp::new("returning"); let mut s = sample(); s.run("offline.download", &json!({"name": "Back", "extent": [-20.0, -40.0, 60.0, 40.0]})).unwrap(); s.run("offline.open", &json!({"name": "Back"})).unwrap(); use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; let gt = GeoTransform { origin_x: 10.0, origin_y: 10.0, pixel_width: 1.0, pixel_height: -1.0 }; let r = Raster::new("relief", 2, 2, vec![vec![1.0; 4]], gt, Crs::from_epsg(4326).unwrap(), None, ColorHint::Gray, "Float32").unwrap(); let tif = temp.path().join("relief.tif"); std::fs::write(&tif, geotiff::write(&r, 0).unwrap()).unwrap(); s.run("layer.addData", &json!({"path": tif.display().to_string()})).unwrap(); s.map_mut().tiles.push(TileLayer::new("Uploaded", TileKind::MbTiles, format!("{}base.mbtiles", rustgis_map::tiles::UPLOAD))); let mark = s.notice_mark(); let (_, bytes) = package(&mut s, &Value::Null).unwrap(); let said: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("raster layer(s) were left out")), "{said:?}"); assert!(said.iter().any(|t| t.contains("Uploaded") && t.contains("memory")), "{said:?}"); assert!(!said.iter().any(|t| t.contains("from the web")), "{said:?}"); // The package holds no raster layer. let text = rustgis_data::zip::read(&bytes).unwrap().into_iter().find(|(n, _)| n.ends_with(".rgis")).unwrap().1; let project = Project::from_import_json(&String::from_utf8(text).unwrap(), None).unwrap(); assert!(project.maps[0].layers.iter().all(|l| !l.is_raster())); } #[test] fn a_map_only_named_offline_is_packaged_as_any_other() { let mut s = sample(); s.run("map.rename", &json!({"name": "Plan (offline)"})).unwrap(); let had = s.project.offline_areas.len(); // A new area, named past the map that already holds "Plan (offline)". let (area, _) = package(&mut s, &json!({"name": "Plan"})).unwrap(); assert_eq!(area, "Plan 2"); assert_eq!(s.project.offline_areas.len(), had + 1); } #[test] fn an_offline_map_is_packaged_as_it_is_to_go_back() { let mut s = sample(); s.run("offline.download", &json!({"name": "Back", "extent": [-20.0, -40.0, 60.0, 40.0]})).unwrap(); s.run("offline.open", &json!({"name": "Back"})).unwrap(); assert_eq!(s.map().name, "Back (offline)"); let areas = s.project.offline_areas.len(); let (area, bytes) = package(&mut s, &Value::Null).unwrap(); assert_eq!(area, "Back"); // No new area: the map already is one. assert_eq!(s.project.offline_areas.len(), areas); let entries = rustgis_data::zip::read(&bytes).unwrap(); assert_eq!(entries[0].0, "Back.rgis"); } /// Layer `name`'s id in the active map. fn layer(s: &Session, name: &str) -> u32 { s.map().layers.iter().find(|l| l.name == name).map(|l| l.id).unwrap() } /// The text of field `field` in row `row` of layer `name`. fn value(s: &Session, name: &str, row: usize, field: &str) -> String { let l = s.map().layers.iter().find(|l| l.name == name).unwrap(); l.dataset.value(row, l.dataset.field_index(field).unwrap()).to_string() } #[test] fn the_fields_edits_and_photos_come_back_to_the_map_they_were_packaged_from() { let temp = Temp::new("round-trip"); let mut home = sample(); home.path = Some(temp.path().join("home.rgis")); let (_, out) = package(&mut home, &json!({"name": "Trip", "extent": [-20.0, -40.0, 60.0, 40.0]})).unwrap(); // In the field: the package opened, a city renamed, one added, one deleted, a photo taken. let unpacked = temp.path().join("field"); let text = unpack_into(&out, &unpacked).unwrap(); let mut field = Session::new(project_in(&text, &unpacked).unwrap()); // As `project.open` marks a project opened from a field package. field.opened_package = Some(temp.path().join("sent.rgisfield")); assert_eq!(field.map().name, "Trip (offline)"); let cities = layer(&field, "Cities"); let name = field.map().layer(cities).unwrap().dataset.fields.iter().find(|f| f.ty == rustgis_data::FieldType::Text).unwrap().name.clone(); let renamed = field.map().layer(cities).unwrap().dataset.features[0].id; field.run("edit.setValue", &json!({"layer": cities, "row": 0, "field": name, "value": "Field Camp"})).unwrap(); field.run("edit.addFeature", &json!({"layer": cities, "geometry": {"type": "Point", "coordinates": [10.0, 5.0]}, "attributes": {name.clone(): "New Site"}})).unwrap(); let gone = field.map().layer(cities).unwrap().dataset.features[1].id; field.run("select.rows", &json!({"layer": cities, "rows": [1]})).unwrap(); field.run("edit.delete", &Value::Null).unwrap(); let photo = temp.path().join("photo-1.jpg"); std::fs::write(&photo, b"jpeg bytes").unwrap(); field.run("edit.addField", &json!({"layer": cities, "name": "photos", "type": "text"})).unwrap(); field.run("edit.setValue", &json!({"layer": cities, "row": 0, "field": "photos", "value": photo.display().to_string()})).unwrap(); // Sent back: the photo goes with it, its feature naming it in the package, and with only // pictures named nothing is said of other files. let mark = field.notice_mark(); let (area, back) = package(&mut field, &Value::Null).unwrap(); assert!(field.notices_since(mark).iter().all(|n| !n.text.contains("only pictures go"))); assert_eq!(area, "Trip"); let entries = rustgis_data::zip::read(&back).unwrap(); assert!(entries.iter().any(|(n, b)| n == "photos/photo-1.jpg" && b == b"jpeg bytes")); // On the phone it was taken on, not here: brought back, it is copied in. std::fs::remove_file(&photo).unwrap(); // Meanwhile at home, the renamed city was changed too: a conflict, said. let row = home.map().layer(layer(&home, "Cities")).unwrap().dataset.features.iter().position(|f| f.id == renamed).unwrap(); let home_cities = layer(&home, "Cities"); home.run("edit.setValue", &json!({"layer": home_cities, "row": row, "field": name, "value": "Home Edit"})).unwrap(); let before = home.map().layer(home_cities).unwrap().dataset.features.len(); let sent = temp.path().join("back.rgisfield"); std::fs::write(&sent, &back).unwrap(); let mark = home.notice_mark(); let done = home.run("offline.bringBack", &json!({"path": sent.display().to_string()})).unwrap(); assert_eq!((done["updated"].as_u64(), done["added"].as_u64(), done["deleted"].as_u64()), (Some(1), Some(1), Some(1)), "{done}"); let home_layer = home.map().layer(home_cities).unwrap(); assert_eq!(home_layer.dataset.features.len(), before); assert!(!home_layer.dataset.features.iter().any(|f| f.id == gone)); let row = home_layer.dataset.features.iter().position(|f| f.id == renamed).unwrap(); assert_eq!(value(&home, "Cities", row, &name), "Field Camp"); // The photo is beside the project, and the feature names it there. let kept = temp.path().join("Trip photos").join("photo-1.jpg"); assert_eq!(std::fs::read(&kept).unwrap(), b"jpeg bytes"); assert_eq!(value(&home, "Cities", row, "photos"), kept.display().to_string()); let said: Vec = home.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("changed in the map") && t.contains("Cities")), "{said:?}"); assert!(said.iter().any(|t| t.contains("Brought the field's edits back") && t.contains("1 photo(s)")), "{said:?}"); // A package for an area this project doesn't have is refused, by name. let mut other = sample(); let e = other.run("offline.bringBack", &json!({"path": sent.display().to_string()})).unwrap_err(); assert!(e.contains("no offline area 'Trip'"), "{e}"); } #[test] fn photos_go_back_only_as_pictures_and_never_replace_one_already_home() { let temp = Temp::new("photos"); let mut home = sample(); home.path = Some(temp.path().join("home.rgis")); let cities = layer(&home, "Cities"); home.run("edit.addField", &json!({"layer": cities, "name": "photos", "type": "text"})).unwrap(); let secret = temp.path().join("notes.txt"); let picture = temp.path().join(".pending.jpg"); std::fs::write(&secret, b"not a picture").unwrap(); std::fs::write(&picture, b"first").unwrap(); let named = format!("{};{}", secret.display(), picture.display()); home.run("edit.setValue", &json!({"layer": cities, "row": 0, "field": "photos", "value": named})).unwrap(); // Going out: no photos, whatever the features name. let (_, out) = package(&mut home, &json!({"name": "Pics", "extent": [-180.0, -85.0, 180.0, 85.0]})).unwrap(); assert!(rustgis_data::zip::read(&out).unwrap().iter().all(|(n, _)| !n.starts_with("photos/"))); // Coming back: the picture only, under a plain name; the text file stays named as it was. let unpacked = temp.path().join("field"); let text = unpack_into(&out, &unpacked).unwrap(); let mut field = Session::new(project_in(&text, &unpacked).unwrap()); // As `project.open` marks a project opened from a field package. field.opened_package = Some(temp.path().join("sent.rgisfield")); let back = |field: &mut Session| package(field, &Value::Null).unwrap().1; let entries = rustgis_data::zip::read(&back(&mut field)).unwrap(); let photos: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).filter(|n| n.starts_with("photos/") && *n != INDEX).collect(); assert_eq!(photos, ["photos/_pending.jpg"]); let first = back(&mut field); // A second package's photo of that name, other bytes: kept beside the first, renamed. std::fs::write(&picture, b"second").unwrap(); let second = back(&mut field); // Brought back where the photo isn't (as from a phone): copied in beside the project. std::fs::remove_file(&picture).unwrap(); let path = temp.path().join("back.rgisfield"); std::fs::write(&path, &first).unwrap(); home.run("offline.bringBack", &json!({"path": path.display().to_string()})).unwrap(); std::fs::write(&path, &second).unwrap(); let mark = home.notice_mark(); home.run("offline.bringBack", &json!({"path": path.display().to_string()})).unwrap(); let dir = temp.path().join("Pics photos"); assert_eq!(std::fs::read(dir.join("_pending.jpg")).unwrap(), b"first"); assert_eq!(std::fs::read(dir.join("2-_pending.jpg")).unwrap(), b"second"); assert!(home.notices_since(mark).iter().any(|n| n.text.contains("under a new name"))); } #[test] fn a_photo_brought_back_to_where_it_already_is_keeps_its_path_and_no_edit_is_counted() { let temp = Temp::new("same-place"); let mut home = sample(); home.path = Some(temp.path().join("home.rgis")); let cities = layer(&home, "Cities"); home.run("edit.addField", &json!({"layer": cities, "name": "photos", "type": "text"})).unwrap(); let photo = temp.path().join("a.jpg"); std::fs::write(&photo, b"a picture").unwrap(); let gif = temp.path().join("b.gif"); std::fs::write(&gif, b"gif").unwrap(); home.run("edit.setValue", &json!({"layer": cities, "row": 0, "field": "photos", "value": photo.display().to_string()})).unwrap(); home.run("edit.setValue", &json!({"layer": cities, "row": 1, "field": "photos", "value": gif.display().to_string()})).unwrap(); let (_, out) = package(&mut home, &json!({"name": "Here", "extent": [-180.0, -85.0, 180.0, 85.0]})).unwrap(); let unpacked = temp.path().join("field"); let text = unpack_into(&out, &unpacked).unwrap(); let mut field = Session::new(project_in(&text, &unpacked).unwrap()); field.opened_package = Some(temp.path().join("sent.rgisfield")); // Sent back with no edit: the picture goes in, listed with its path; the gif is said. let mark = field.notice_mark(); let (_, back) = package(&mut field, &Value::Null).unwrap(); let said: Vec = field.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.contains("only pictures go") && t.contains("b.gif")), "{said:?}"); let entries = rustgis_data::zip::read(&back).unwrap(); assert!(entries.iter().any(|(n, b)| n == INDEX && String::from_utf8_lossy(b).contains(&*photo.display().to_string()))); // Brought back here: the photo is where it was, so nothing changed and nothing is copied. let sent = temp.path().join("back.rgisfield"); std::fs::write(&sent, &back).unwrap(); let done = home.run("offline.bringBack", &json!({"path": sent.display().to_string()})).unwrap(); assert_eq!((done["updated"].as_u64(), done["conflicts"].as_u64()), (Some(0), Some(0)), "{done}"); assert_eq!(value(&home, "Cities", 0, "photos"), photo.display().to_string()); assert!(!temp.path().join("Here photos").join("a.jpg").exists()); } #[test] fn a_photo_in_a_folder_with_a_semicolon_goes_and_other_text_is_kept_as_written() { let temp = Temp::new("semicolon"); let mut home = sample(); home.path = Some(temp.path().join("home.rgis")); let cities = layer(&home, "Cities"); home.run("edit.addField", &json!({"layer": cities, "name": "photos", "type": "text"})).unwrap(); let (_, out) = package(&mut home, &json!({"name": "Semi", "extent": [-180.0, -85.0, 180.0, 85.0]})).unwrap(); let unpacked = temp.path().join("field"); let text = unpack_into(&out, &unpacked).unwrap(); let mut field = Session::new(project_in(&text, &unpacked).unwrap()); field.opened_package = Some(temp.path().join("sent.rgisfield")); let cities = layer(&field, "Cities"); // A picture in a folder with a `;` in its name, and a gif named by two features. let trips = temp.path().join("Trips;2024"); std::fs::create_dir_all(&trips).unwrap(); let photo = trips.join("IMG_1.jpg"); std::fs::write(&photo, b"jpeg").unwrap(); let gif = temp.path().join("b.gif"); std::fs::write(&gif, b"gif").unwrap(); let first = format!("{}; {}", photo.display(), gif.display()); let spaced = "notes.jpg ; other.jpg"; for (row, value) in [(0, first.as_str()), (1, &*gif.display().to_string()), (2, spaced)] { field.run("edit.setValue", &json!({"layer": cities, "row": row, "field": "photos", "value": value})).unwrap(); } let mark = field.notice_mark(); let (_, back) = package(&mut field, &Value::Null).unwrap(); let entries = rustgis_data::zip::read(&back).unwrap(); assert!(entries.iter().any(|(n, b)| n == "photos/IMG_1.jpg" && b == b"jpeg")); // The gif is said once, however many name it. let said: Vec = field.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.starts_with("1 file(s)") && t.contains("b.gif")), "{said:?}"); // Text naming nothing here is kept as it was written, spaces and all. let text = entries.iter().find(|(n, _)| n.ends_with(".rgis")).map(|(_, b)| String::from_utf8_lossy(b).into_owned()).unwrap(); assert!(text.contains(spaced), "{text}"); } #[test] fn photo_names_a_system_refuses_are_made_plain_and_left_out_coming_back() { for (name, plain) in [("a:b*?.jpg", "a_b__.jpg"), ("CON.jpg", "_CON.jpg"), ("com1", "_com1"), (".x.jpg", "_x.jpg"), ("x.jpg.", "x.jpg")] { assert_eq!(plain_name(name), plain); assert!(!plain_file(name) && plain_file(plain), "{name}"); } assert!(plain_file("IMG_1.jpg") && plain_file("COM10.jpg") && plain_file("console.jpg")); // A package with photo entries of such names: they are left out and counted. let temp = Temp::new("strange"); let mut home = sample(); home.path = Some(temp.path().join("home.rgis")); let (_, out) = package(&mut home, &json!({"name": "Odd", "extent": [-180.0, -85.0, 180.0, 85.0]})).unwrap(); let unpacked = temp.path().join("field"); let text = unpack_into(&out, &unpacked).unwrap(); let mut field = Session::new(project_in(&text, &unpacked).unwrap()); field.opened_package = Some(temp.path().join("sent.rgisfield")); let (_, back) = package(&mut field, &Value::Null).unwrap(); let path = temp.path().join("back.rgisfield"); // Clean: nothing said of photos left out. std::fs::write(&path, &back).unwrap(); let mark = home.notice_mark(); home.run("offline.bringBack", &json!({"path": path.display().to_string()})).unwrap(); assert!(home.notices_since(mark).iter().all(|n| !n.text.contains("plain file names"))); let mut entries = rustgis_data::zip::read(&back).unwrap(); for odd in ["photos/.hidden.jpg", "photos/NUL.jpg", "photos/a*b.jpg"] { entries.push((odd.into(), b"x".to_vec())); } std::fs::write(&path, rustgis_data::zip::write(&entries).unwrap()).unwrap(); let mark = home.notice_mark(); home.run("offline.bringBack", &json!({"path": path.display().to_string()})).unwrap(); let said: Vec = home.notices_since(mark).iter().map(|n| n.text.clone()).collect(); assert!(said.iter().any(|t| t.starts_with("3 photo(s)") && t.contains("plain file names")), "{said:?}"); assert!(!temp.path().join("Odd photos").exists()); } #[test] fn a_package_entry_outside_its_folder_is_refused() { let temp = Temp::new("refused"); for bad in ["../escape.txt", "/abs.txt", "a/../../b.txt", "C:/x.txt"] { let bytes = rustgis_data::zip::write(&[("p.rgis".into(), b"{}".to_vec()), (bad.into(), b"x".to_vec())]).unwrap(); let e = unpack_into(&bytes, &temp.path().join("in")).unwrap_err(); assert!(e.contains("outside"), "{bad}: {e}"); } // Two projects: refused. One that can't be read: refused, with nothing written. let two = rustgis_data::zip::write(&[("a.rgis".into(), b"{}".to_vec()), ("b.rgis".into(), b"{}".to_vec())]).unwrap(); assert!(unpack_into(&two, &temp.path().join("two")).unwrap_err().contains("more than one project")); let damaged = rustgis_data::zip::write(&[("a.rgis".into(), b"not json".to_vec()), ("basemaps/b.mbtiles".into(), b"x".to_vec())]).unwrap(); assert!(unpack_into(&damaged, &temp.path().join("damaged")).is_err()); assert!(!temp.path().join("damaged").exists()); let none = rustgis_data::zip::write(&[("a.txt".into(), b"x".to_vec())]).unwrap(); assert!(unpack_into(&none, &temp.path().join("in")).unwrap_err().contains("no project")); assert!(is_field_package("Site.RGISFIELD") && !is_field_package("site.rgis")); assert!(crate::project::opens_as_project("site.rgisfield")); } }