Skip to content

Commit c2b2e56

Browse files
committed
Allow for applying changes
1 parent 120d1ae commit c2b2e56

3 files changed

Lines changed: 254 additions & 1 deletion

File tree

crates/ofd-validator-core/src/orchestrator.rs

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use std::collections::HashSet;
2+
use std::path::Path;
3+
use serde::Deserialize;
24
use serde_json::Value;
35
use rayon::prelude::*;
46

@@ -26,6 +28,220 @@ pub struct DataSet {
2628
pub schema_cache: SchemaCache,
2729
}
2830

31+
// --- Types for applying pending changes from the WebUI ---
32+
33+
#[derive(Deserialize)]
34+
struct ChangesPayload {
35+
changes: Vec<ChangeEntry>,
36+
}
37+
38+
#[derive(Deserialize)]
39+
struct ChangeEntry {
40+
entity: EntityRef,
41+
operation: String,
42+
data: Option<Value>,
43+
}
44+
45+
#[derive(Deserialize)]
46+
struct EntityRef {
47+
path: String,
48+
#[serde(rename = "type")]
49+
entity_type: String,
50+
}
51+
52+
/// Internal fields added by the WebUI that should be stripped before validation.
53+
const STRIP_FIELDS: &[&str] = &[
54+
"brandId", "brand_id", "materialType", "filamentDir", "filament_id", "slug",
55+
];
56+
57+
/// Info needed to overlay a change onto the DataSet.
58+
struct EntityFsInfo {
59+
json_path: String,
60+
folder_path: String,
61+
folder_name: String,
62+
schema_name: String,
63+
folder_json_key: String,
64+
}
65+
66+
/// Map a WebUI entity path to filesystem paths and metadata.
67+
fn entity_path_to_fs_info(
68+
entity_path: &str,
69+
entity_type: &str,
70+
data_dir: &Path,
71+
stores_dir: &Path,
72+
) -> Option<EntityFsInfo> {
73+
let parts: Vec<&str> = entity_path.split('/').collect();
74+
75+
let (json_path, folder_path, folder_name, folder_json_key) = match parts.as_slice() {
76+
["stores", slug] => (
77+
stores_dir.join(slug).join("store.json"),
78+
stores_dir.join(slug),
79+
slug.to_string(),
80+
"id".to_string(),
81+
),
82+
["brands", slug] => (
83+
data_dir.join(slug).join("brand.json"),
84+
data_dir.join(slug),
85+
slug.to_string(),
86+
"id".to_string(),
87+
),
88+
["brands", slug, "materials", mat_type] => (
89+
data_dir.join(slug).join(mat_type).join("material.json"),
90+
data_dir.join(slug).join(mat_type),
91+
mat_type.to_string(),
92+
"material".to_string(),
93+
),
94+
["brands", slug, "materials", mat_type, "filaments", name] => (
95+
data_dir.join(slug).join(mat_type).join(name).join("filament.json"),
96+
data_dir.join(slug).join(mat_type).join(name),
97+
name.to_string(),
98+
"id".to_string(),
99+
),
100+
["brands", slug, "materials", mat_type, "filaments", name, "variants", variant] => (
101+
data_dir.join(slug).join(mat_type).join(name).join(variant).join("variant.json"),
102+
data_dir.join(slug).join(mat_type).join(name).join(variant),
103+
variant.to_string(),
104+
"id".to_string(),
105+
),
106+
_ => return None,
107+
};
108+
109+
Some(EntityFsInfo {
110+
json_path: json_path.to_string_lossy().to_string(),
111+
folder_path: folder_path.to_string_lossy().to_string(),
112+
folder_name,
113+
schema_name: entity_type.to_string(),
114+
folder_json_key,
115+
})
116+
}
117+
118+
/// Strip internal WebUI tracking fields and empty strings from entity data.
119+
fn clean_change_data(data: &Value) -> Value {
120+
if let Value::Object(map) = data {
121+
let mut clean = serde_json::Map::new();
122+
for (key, value) in map {
123+
if STRIP_FIELDS.contains(&key.as_str()) {
124+
continue;
125+
}
126+
if value == &Value::String(String::new()) {
127+
continue;
128+
}
129+
clean.insert(key.clone(), value.clone());
130+
}
131+
Value::Object(clean)
132+
} else {
133+
data.clone()
134+
}
135+
}
136+
137+
impl DataSet {
138+
/// Apply pending changes from the WebUI onto the in-memory dataset.
139+
///
140+
/// `changes_json` should be a JSON string with the format:
141+
/// `{ "changes": [{ "entity": { "path": "...", "type": "..." }, "operation": "create"|"update"|"delete", "data": {...} }] }`
142+
pub fn apply_changes(&mut self, changes_json: &str, data_dir: &Path, stores_dir: &Path) {
143+
let payload: ChangesPayload = match serde_json::from_str(changes_json) {
144+
Ok(p) => p,
145+
Err(e) => {
146+
eprintln!("Warning: failed to parse changes JSON: {}", e);
147+
return;
148+
}
149+
};
150+
151+
for change in &payload.changes {
152+
let info = match entity_path_to_fs_info(
153+
&change.entity.path,
154+
&change.entity.entity_type,
155+
data_dir,
156+
stores_dir,
157+
) {
158+
Some(i) => i,
159+
None => continue,
160+
};
161+
162+
match change.operation.as_str() {
163+
"create" | "update" => {
164+
let data = match &change.data {
165+
Some(d) => clean_change_data(d),
166+
None => continue,
167+
};
168+
169+
// Update or add json_entries
170+
if let Some(existing) = self.json_entries.iter_mut().find(|(p, _, _)| *p == info.json_path) {
171+
existing.2 = data.clone();
172+
} else {
173+
self.json_entries.push((
174+
info.json_path.clone(),
175+
info.schema_name.clone(),
176+
data.clone(),
177+
));
178+
}
179+
180+
// Update or add folder_entries
181+
if let Some(existing) = self.folder_entries.iter_mut().find(|(p, _, _, _)| *p == info.folder_path) {
182+
existing.2 = data.clone();
183+
} else {
184+
self.folder_entries.push((
185+
info.folder_path.clone(),
186+
info.folder_name.clone(),
187+
data.clone(),
188+
info.folder_json_key.clone(),
189+
));
190+
}
191+
192+
// For stores: update valid_store_ids
193+
if info.schema_name == "store" {
194+
if let Some(id) = data.get("id").and_then(|v| v.as_str()) {
195+
self.valid_store_ids.insert(id.to_string());
196+
}
197+
}
198+
199+
// For variants with sizes: update sizes_entries
200+
if info.schema_name == "variant" {
201+
if let Some(sizes) = data.get("sizes") {
202+
let sizes_path = Path::new(&info.folder_path)
203+
.join("sizes.json")
204+
.to_string_lossy()
205+
.to_string();
206+
if let Some(existing) = self.sizes_entries.iter_mut().find(|(p, _)| *p == sizes_path) {
207+
existing.1 = sizes.clone();
208+
} else {
209+
self.sizes_entries.push((sizes_path, sizes.clone()));
210+
}
211+
}
212+
}
213+
}
214+
"delete" => {
215+
// Remove from json_entries
216+
self.json_entries.retain(|(p, _, _)| *p != info.json_path);
217+
// Remove from folder_entries
218+
self.folder_entries.retain(|(p, _, _, _)| *p != info.folder_path);
219+
// Remove logo entries for this folder
220+
self.logo_entries.retain(|(p, _, _, _)| !p.starts_with(&info.folder_path));
221+
// Remove sizes entries under this folder
222+
self.sizes_entries.retain(|(p, _)| !p.starts_with(&info.folder_path));
223+
224+
// For stores: remove from valid_store_ids
225+
if info.schema_name == "store" {
226+
// Extract the ID from the path (last component)
227+
if let Some(slug) = change.entity.path.split('/').last() {
228+
self.valid_store_ids.remove(slug);
229+
}
230+
}
231+
232+
// Also remove any child entries (e.g., deleting a brand removes its materials/filaments/variants)
233+
let prefix = format!("{}/", info.folder_path);
234+
self.json_entries.retain(|(p, _, _)| !p.starts_with(&prefix));
235+
self.folder_entries.retain(|(p, _, _, _)| !p.starts_with(&prefix));
236+
self.logo_entries.retain(|(p, _, _, _)| !p.starts_with(&prefix));
237+
self.sizes_entries.retain(|(p, _)| !p.starts_with(&prefix));
238+
}
239+
_ => {}
240+
}
241+
}
242+
}
243+
}
244+
29245
#[cfg(feature = "filesystem")]
30246
impl DataSet {
31247
/// Build a DataSet by walking the filesystem.

crates/ofd-validator-python/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ mod util;
66
mod validators;
77

88
use orchestrator::{
9-
validate_all, validate_folder_names, validate_json_files, validate_logo_files,
9+
validate_all, validate_all_with_changes, validate_folder_names, validate_json_files,
10+
validate_logo_files,
1011
};
1112
use types::{ValidationError, ValidationLevel, ValidationResult};
1213
use validators::{
@@ -22,6 +23,7 @@ fn ofd_validator(m: &Bound<'_, PyModule>) -> PyResult<()> {
2223

2324
// Orchestrated batch validators (internally parallel with rayon)
2425
m.add_function(wrap_pyfunction!(validate_all, m)?)?;
26+
m.add_function(wrap_pyfunction!(validate_all_with_changes, m)?)?;
2527
m.add_function(wrap_pyfunction!(validate_json_files, m)?)?;
2628
m.add_function(wrap_pyfunction!(validate_logo_files, m)?)?;
2729
m.add_function(wrap_pyfunction!(validate_folder_names, m)?)?;

crates/ofd-validator-python/src/orchestrator.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,41 @@ pub fn validate_all(
5757
})
5858
}
5959

60+
#[pyfunction]
61+
#[pyo3(signature = (data_dir, stores_dir, changes_json, schemas_dir=None, max_workers=None))]
62+
pub fn validate_all_with_changes(
63+
py: Python<'_>,
64+
data_dir: &str,
65+
stores_dir: &str,
66+
changes_json: &str,
67+
schemas_dir: Option<&str>,
68+
max_workers: Option<usize>,
69+
) -> ValidationResult {
70+
let data_dir = PathBuf::from(data_dir);
71+
let stores_dir = PathBuf::from(stores_dir);
72+
let schemas_dir = PathBuf::from(schemas_dir.unwrap_or("schemas"));
73+
let changes_json = changes_json.to_string();
74+
75+
py.allow_threads(|| {
76+
with_thread_pool(max_workers, || {
77+
log_step("Loading dataset", None);
78+
let mut dataset = core::DataSet::from_directories(&data_dir, &stores_dir, &schemas_dir);
79+
80+
log_step("Applying pending changes", None);
81+
dataset.apply_changes(&changes_json, &data_dir, &stores_dir);
82+
83+
log_step("Checking required files", None);
84+
log_step("Validating JSON schemas", Some(dataset.json_entries.len()));
85+
log_step("Validating logos", Some(dataset.logo_entries.len()));
86+
log_step("Validating folder names", Some(dataset.folder_entries.len()));
87+
log_step("Validating store IDs", None);
88+
log_step("Validating GTIN/EAN codes", None);
89+
90+
core::validate_dataset(&dataset).into()
91+
})
92+
})
93+
}
94+
6095
#[pyfunction]
6196
#[pyo3(signature = (data_dir, stores_dir, schemas_dir=None, max_workers=None))]
6297
pub fn validate_json_files(

0 commit comments

Comments
 (0)