ofd-validator 0.5.0__tar.gz → 0.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/Cargo.lock +4 -3
- ofd_validator-0.5.2/Cargo.toml +3 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/PKG-INFO +1 -1
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/Cargo.toml +2 -1
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/orchestrator.rs +251 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/util.rs +7 -0
- ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/duplicate_link.rs +138 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/mod.rs +8 -0
- ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/store_host.rs +138 -0
- ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/url_tracker.rs +229 -0
- ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/variant_material.rs +120 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/Cargo.toml +1 -1
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/lib.rs +3 -1
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/orchestrator.rs +35 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/pyproject.toml +1 -1
- ofd_validator-0.5.0/Cargo.toml +0 -3
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/README.md +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/lib.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/schema_cache.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/types.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/folder_name.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/gtin.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/json_validator.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/logo_validator.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/missing_files.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/store_id.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/types.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/util.rs +0 -0
- {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/validators.rs +0 -0
|
@@ -805,7 +805,7 @@ dependencies = [
|
|
|
805
805
|
|
|
806
806
|
[[package]]
|
|
807
807
|
name = "ofd-validator-core"
|
|
808
|
-
version = "0.5.
|
|
808
|
+
version = "0.5.2"
|
|
809
809
|
dependencies = [
|
|
810
810
|
"image",
|
|
811
811
|
"jsonschema",
|
|
@@ -813,12 +813,13 @@ dependencies = [
|
|
|
813
813
|
"regex",
|
|
814
814
|
"serde",
|
|
815
815
|
"serde_json",
|
|
816
|
+
"url",
|
|
816
817
|
"walkdir",
|
|
817
818
|
]
|
|
818
819
|
|
|
819
820
|
[[package]]
|
|
820
821
|
name = "ofd-validator-js"
|
|
821
|
-
version = "0.5.
|
|
822
|
+
version = "0.5.2"
|
|
822
823
|
dependencies = [
|
|
823
824
|
"napi",
|
|
824
825
|
"napi-build",
|
|
@@ -831,7 +832,7 @@ dependencies = [
|
|
|
831
832
|
|
|
832
833
|
[[package]]
|
|
833
834
|
name = "ofd-validator-python"
|
|
834
|
-
version = "0.5.
|
|
835
|
+
version = "0.5.2"
|
|
835
836
|
dependencies = [
|
|
836
837
|
"ofd-validator-core",
|
|
837
838
|
"pyo3",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "ofd-validator-core"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.2"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["Open Filament Collective"]
|
|
6
6
|
license = "MIT"
|
|
@@ -11,6 +11,7 @@ serde = { version = "1", features = ["derive"] }
|
|
|
11
11
|
serde_json = "1"
|
|
12
12
|
jsonschema = "0.28"
|
|
13
13
|
regex = "1"
|
|
14
|
+
url = "2"
|
|
14
15
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
|
15
16
|
rayon = "1"
|
|
16
17
|
walkdir = { version = "2", optional = true }
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
use std::collections::HashSet;
|
|
2
|
+
use std::path::Path;
|
|
3
|
+
use serde::Deserialize;
|
|
2
4
|
use serde_json::Value;
|
|
3
5
|
use rayon::prelude::*;
|
|
4
6
|
|
|
@@ -26,6 +28,220 @@ pub struct DataSet {
|
|
|
26
28
|
pub schema_cache: SchemaCache,
|
|
27
29
|
}
|
|
28
30
|
|
|
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
|
+
|
|
29
245
|
#[cfg(feature = "filesystem")]
|
|
30
246
|
impl DataSet {
|
|
31
247
|
/// Build a DataSet by walking the filesystem.
|
|
@@ -356,5 +572,40 @@ pub fn validate_dataset(dataset: &DataSet) -> ValidationResult {
|
|
|
356
572
|
// 6. GTIN/EAN validation
|
|
357
573
|
result.merge_from(&validators::validate_gtin_ean(&sizes_refs));
|
|
358
574
|
|
|
575
|
+
// 7. URL tracking parameters in purchase links (warnings)
|
|
576
|
+
result.merge_from(&validators::validate_url_tracking(&sizes_refs));
|
|
577
|
+
|
|
578
|
+
// 8. Variant colour-name / folder-id material-type redundancy (warnings)
|
|
579
|
+
let material_types: HashSet<String> = dataset
|
|
580
|
+
.schema_cache
|
|
581
|
+
.get("material_types")
|
|
582
|
+
.and_then(|s| s.get("enum"))
|
|
583
|
+
.and_then(|e| e.as_array())
|
|
584
|
+
.map(|arr| {
|
|
585
|
+
arr.iter()
|
|
586
|
+
.filter_map(|v| v.as_str().map(|s| s.to_ascii_uppercase()))
|
|
587
|
+
.collect()
|
|
588
|
+
})
|
|
589
|
+
.unwrap_or_else(|| {
|
|
590
|
+
validators::variant_material::MATERIAL_TYPES_FALLBACK
|
|
591
|
+
.iter()
|
|
592
|
+
.map(|s| s.to_string())
|
|
593
|
+
.collect()
|
|
594
|
+
});
|
|
595
|
+
let variant_refs: Vec<(&str, &Value)> = dataset
|
|
596
|
+
.json_entries
|
|
597
|
+
.iter()
|
|
598
|
+
.filter(|(_, schema_name, _)| schema_name == "variant")
|
|
599
|
+
.map(|(p, _, v)| (p.as_str(), v))
|
|
600
|
+
.collect();
|
|
601
|
+
result.merge_from(&validators::validate_variant_material(&variant_refs, &material_types));
|
|
602
|
+
|
|
603
|
+
// 9. Purchase-link host consistency vs the store's storefront (warnings)
|
|
604
|
+
let store_hosts = validators::store_host::build_store_hosts(&dataset.json_entries);
|
|
605
|
+
result.merge_from(&validators::validate_store_host(&store_hosts, &sizes_refs));
|
|
606
|
+
|
|
607
|
+
// 10. Generic purchase link reused across many variants of a filament (warnings)
|
|
608
|
+
result.merge_from(&validators::validate_duplicate_links(&sizes_refs));
|
|
609
|
+
|
|
359
610
|
result
|
|
360
611
|
}
|
|
@@ -16,6 +16,13 @@ pub fn cleanse_folder_name(name: &str) -> String {
|
|
|
16
16
|
name.replace('/', " ").trim().to_string()
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/// Extract the host (e.g. `shop.polymaker.com`) from an absolute URL, if parseable.
|
|
20
|
+
pub fn url_host(url: &str) -> Option<String> {
|
|
21
|
+
url::Url::parse(url)
|
|
22
|
+
.ok()
|
|
23
|
+
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
#[cfg(feature = "filesystem")]
|
|
20
27
|
pub fn load_json(path: &std::path::Path) -> Option<Value> {
|
|
21
28
|
let content = std::fs::read_to_string(path).ok()?;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
use serde_json::Value;
|
|
2
|
+
use std::collections::{BTreeSet, HashMap};
|
|
3
|
+
|
|
4
|
+
use crate::types::{ValidationError, ValidationResult};
|
|
5
|
+
|
|
6
|
+
/// Minimum number of distinct variants sharing one URL before we warn.
|
|
7
|
+
const MIN_VARIANTS: usize = 3;
|
|
8
|
+
|
|
9
|
+
/// From a sizes.json path label `.../<brand>/<MAT>/<filament>/<variant>/sizes.json`,
|
|
10
|
+
/// return `(filament_key, variant_folder)` = (`.../<brand>/<MAT>/<filament>`, `<variant>`).
|
|
11
|
+
fn filament_key(path: &str) -> Option<(String, String)> {
|
|
12
|
+
let norm = path.replace('\\', "/");
|
|
13
|
+
let parts: Vec<&str> = norm.split('/').collect();
|
|
14
|
+
if parts.len() < 3 {
|
|
15
|
+
return None;
|
|
16
|
+
}
|
|
17
|
+
let variant = parts[parts.len() - 2].to_string();
|
|
18
|
+
let key = parts[..parts.len() - 2].join("/");
|
|
19
|
+
Some((key, variant))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Warn when the same purchase URL is reused across many variants of one filament (a generic
|
|
23
|
+
/// link copied to every colour). Non-blocking; one warning per (filament, url).
|
|
24
|
+
pub fn validate_duplicate_links(sizes_entries: &[(&str, &Value)]) -> ValidationResult {
|
|
25
|
+
let mut result = ValidationResult::default();
|
|
26
|
+
|
|
27
|
+
// filament_key -> url -> set of variant folder names
|
|
28
|
+
let mut map: HashMap<String, HashMap<String, BTreeSet<String>>> = HashMap::new();
|
|
29
|
+
|
|
30
|
+
for (path_str, sizes_data) in sizes_entries {
|
|
31
|
+
let (fkey, variant) = match filament_key(path_str) {
|
|
32
|
+
Some(v) => v,
|
|
33
|
+
None => continue,
|
|
34
|
+
};
|
|
35
|
+
let sizes_arr = match sizes_data.as_array() {
|
|
36
|
+
Some(a) => a,
|
|
37
|
+
None => continue,
|
|
38
|
+
};
|
|
39
|
+
for size in sizes_arr {
|
|
40
|
+
let purchase_links = match size.get("purchase_links").and_then(|v| v.as_array()) {
|
|
41
|
+
Some(l) => l,
|
|
42
|
+
None => continue,
|
|
43
|
+
};
|
|
44
|
+
for link in purchase_links {
|
|
45
|
+
if let Some(url) = link.get("url").and_then(|v| v.as_str()) {
|
|
46
|
+
map.entry(fkey.clone())
|
|
47
|
+
.or_default()
|
|
48
|
+
.entry(url.to_string())
|
|
49
|
+
.or_default()
|
|
50
|
+
.insert(variant.clone());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Deterministic output ordering.
|
|
57
|
+
let mut filaments: Vec<&String> = map.keys().collect();
|
|
58
|
+
filaments.sort();
|
|
59
|
+
for fkey in filaments {
|
|
60
|
+
let urls = &map[fkey];
|
|
61
|
+
let mut url_keys: Vec<&String> = urls.keys().collect();
|
|
62
|
+
url_keys.sort();
|
|
63
|
+
for url in url_keys {
|
|
64
|
+
let variants = &urls[url];
|
|
65
|
+
if variants.len() >= MIN_VARIANTS {
|
|
66
|
+
result.add(ValidationError::warning(
|
|
67
|
+
"DuplicateLink",
|
|
68
|
+
format!(
|
|
69
|
+
"The same purchase url is reused across {} variants of this filament: {}",
|
|
70
|
+
variants.len(),
|
|
71
|
+
url
|
|
72
|
+
),
|
|
73
|
+
Some(format!("{}/*/sizes.json", fkey)),
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
result
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[cfg(test)]
|
|
83
|
+
mod tests {
|
|
84
|
+
use super::*;
|
|
85
|
+
use serde_json::json;
|
|
86
|
+
|
|
87
|
+
fn sized(url: &str) -> Value {
|
|
88
|
+
json!([{
|
|
89
|
+
"filament_weight": 1000, "diameter": 1.75,
|
|
90
|
+
"purchase_links": [{ "store_id": "s", "url": url }]
|
|
91
|
+
}])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[test]
|
|
95
|
+
fn flags_generic_link_reused_across_variants() {
|
|
96
|
+
let base = "data/b/PLA/snapspeed_pla";
|
|
97
|
+
let a = sized("https://a.com/generic");
|
|
98
|
+
let b = sized("https://a.com/generic");
|
|
99
|
+
let c = sized("https://a.com/generic");
|
|
100
|
+
let entries = vec![
|
|
101
|
+
(format!("{base}/red/sizes.json"), &a),
|
|
102
|
+
(format!("{base}/blue/sizes.json"), &b),
|
|
103
|
+
(format!("{base}/green/sizes.json"), &c),
|
|
104
|
+
];
|
|
105
|
+
let refs: Vec<(&str, &Value)> = entries.iter().map(|(p, v)| (p.as_str(), *v)).collect();
|
|
106
|
+
let res = validate_duplicate_links(&refs);
|
|
107
|
+
assert_eq!(res.warning_count(), 1);
|
|
108
|
+
assert!(res.is_valid());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[test]
|
|
112
|
+
fn distinct_links_pass() {
|
|
113
|
+
let base = "data/b/PLA/f";
|
|
114
|
+
let a = sized("https://a.com/1");
|
|
115
|
+
let b = sized("https://a.com/2");
|
|
116
|
+
let c = sized("https://a.com/3");
|
|
117
|
+
let entries = vec![
|
|
118
|
+
(format!("{base}/red/sizes.json"), &a),
|
|
119
|
+
(format!("{base}/blue/sizes.json"), &b),
|
|
120
|
+
(format!("{base}/green/sizes.json"), &c),
|
|
121
|
+
];
|
|
122
|
+
let refs: Vec<(&str, &Value)> = entries.iter().map(|(p, v)| (p.as_str(), *v)).collect();
|
|
123
|
+
assert_eq!(validate_duplicate_links(&refs).warning_count(), 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#[test]
|
|
127
|
+
fn same_url_across_two_variants_below_threshold() {
|
|
128
|
+
let base = "data/b/PLA/f";
|
|
129
|
+
let a = sized("https://a.com/x");
|
|
130
|
+
let b = sized("https://a.com/x");
|
|
131
|
+
let entries = vec![
|
|
132
|
+
(format!("{base}/red/sizes.json"), &a),
|
|
133
|
+
(format!("{base}/blue/sizes.json"), &b),
|
|
134
|
+
];
|
|
135
|
+
let refs: Vec<(&str, &Value)> = entries.iter().map(|(p, v)| (p.as_str(), *v)).collect();
|
|
136
|
+
assert_eq!(validate_duplicate_links(&refs).warning_count(), 0);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
+
mod duplicate_link;
|
|
1
2
|
mod folder_name;
|
|
2
3
|
mod gtin;
|
|
3
4
|
mod json_validator;
|
|
4
5
|
mod logo_validator;
|
|
5
6
|
pub mod missing_files;
|
|
7
|
+
pub mod store_host;
|
|
6
8
|
mod store_id;
|
|
9
|
+
mod url_tracker;
|
|
10
|
+
pub mod variant_material;
|
|
7
11
|
|
|
12
|
+
pub use duplicate_link::validate_duplicate_links;
|
|
8
13
|
pub use folder_name::validate_folder_name;
|
|
9
14
|
pub use gtin::validate_gtin_ean;
|
|
10
15
|
pub use json_validator::validate_json;
|
|
11
16
|
pub use logo_validator::validate_logo;
|
|
12
17
|
pub use missing_files::validate_required_files;
|
|
18
|
+
pub use store_host::validate_store_host;
|
|
13
19
|
pub use store_id::validate_store_ids;
|
|
20
|
+
pub use url_tracker::validate_url_tracking;
|
|
21
|
+
pub use variant_material::validate_variant_material;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
use serde_json::Value;
|
|
2
|
+
use std::collections::HashMap;
|
|
3
|
+
|
|
4
|
+
use crate::types::{ValidationError, ValidationResult};
|
|
5
|
+
use crate::util::url_host;
|
|
6
|
+
|
|
7
|
+
/// Build a map of `store_id -> canonical host` from store.json entries.
|
|
8
|
+
/// `json_entries` are (path_label, schema_name, Value).
|
|
9
|
+
pub fn build_store_hosts(json_entries: &[(String, String, Value)]) -> HashMap<String, String> {
|
|
10
|
+
let mut map = HashMap::new();
|
|
11
|
+
for (_, schema_name, data) in json_entries {
|
|
12
|
+
if schema_name != "store" {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
let id = data.get("id").and_then(|v| v.as_str());
|
|
16
|
+
let host = data
|
|
17
|
+
.get("storefront_url")
|
|
18
|
+
.and_then(|v| v.as_str())
|
|
19
|
+
.and_then(url_host);
|
|
20
|
+
if let (Some(id), Some(host)) = (id, host) {
|
|
21
|
+
map.insert(id.to_string(), host);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
map
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// The registrable-ish base domain: the last two dot-separated labels.
|
|
28
|
+
/// `shop.polymaker.com` and `us.polymaker.com` both -> `polymaker.com`.
|
|
29
|
+
fn base_domain(host: &str) -> String {
|
|
30
|
+
let labels: Vec<&str> = host.split('.').collect();
|
|
31
|
+
let n = labels.len();
|
|
32
|
+
if n >= 2 {
|
|
33
|
+
labels[n - 2..].join(".")
|
|
34
|
+
} else {
|
|
35
|
+
host.to_string()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Warn when a purchase-link URL's host differs from the store's storefront host but shares
|
|
40
|
+
/// the same base domain (an inconsistent subdomain, e.g. `shop.` vs `us.`). Cross-domain
|
|
41
|
+
/// links are left alone (they may be intentional resellers). Non-blocking warnings.
|
|
42
|
+
pub fn validate_store_host(
|
|
43
|
+
store_hosts: &HashMap<String, String>,
|
|
44
|
+
sizes_entries: &[(&str, &Value)],
|
|
45
|
+
) -> ValidationResult {
|
|
46
|
+
let mut result = ValidationResult::default();
|
|
47
|
+
|
|
48
|
+
for (path_str, sizes_data) in sizes_entries {
|
|
49
|
+
let sizes_arr = match sizes_data.as_array() {
|
|
50
|
+
Some(a) => a,
|
|
51
|
+
None => continue,
|
|
52
|
+
};
|
|
53
|
+
for (size_idx, size) in sizes_arr.iter().enumerate() {
|
|
54
|
+
let purchase_links = match size.get("purchase_links").and_then(|v| v.as_array()) {
|
|
55
|
+
Some(l) => l,
|
|
56
|
+
None => continue,
|
|
57
|
+
};
|
|
58
|
+
for (link_idx, link) in purchase_links.iter().enumerate() {
|
|
59
|
+
let store_id = match link.get("store_id").and_then(|v| v.as_str()) {
|
|
60
|
+
Some(s) => s,
|
|
61
|
+
None => continue,
|
|
62
|
+
};
|
|
63
|
+
let canonical = match store_hosts.get(store_id) {
|
|
64
|
+
Some(h) => h,
|
|
65
|
+
None => continue,
|
|
66
|
+
};
|
|
67
|
+
let link_host = match link.get("url").and_then(|v| v.as_str()).and_then(url_host) {
|
|
68
|
+
Some(h) => h,
|
|
69
|
+
None => continue,
|
|
70
|
+
};
|
|
71
|
+
if &link_host != canonical && base_domain(&link_host) == base_domain(canonical) {
|
|
72
|
+
result.add(ValidationError::warning(
|
|
73
|
+
"StoreHost",
|
|
74
|
+
format!(
|
|
75
|
+
"url host '{}' at $[{}].purchase_links[{}] differs from store '{}' storefront host '{}'",
|
|
76
|
+
link_host, size_idx, link_idx, store_id, canonical
|
|
77
|
+
),
|
|
78
|
+
Some(path_str.to_string()),
|
|
79
|
+
));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
result
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#[cfg(test)]
|
|
89
|
+
mod tests {
|
|
90
|
+
use super::*;
|
|
91
|
+
use serde_json::json;
|
|
92
|
+
|
|
93
|
+
fn hosts() -> HashMap<String, String> {
|
|
94
|
+
let mut m = HashMap::new();
|
|
95
|
+
m.insert("polymaker".to_string(), "us.polymaker.com".to_string());
|
|
96
|
+
m
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#[test]
|
|
100
|
+
fn flags_subdomain_drift() {
|
|
101
|
+
let sizes = json!([{
|
|
102
|
+
"filament_weight": 1000, "diameter": 1.75,
|
|
103
|
+
"purchase_links": [{ "store_id": "polymaker", "url": "https://shop.polymaker.com/products/x" }]
|
|
104
|
+
}]);
|
|
105
|
+
let entries = vec![("data/b/PLA/f/red/sizes.json", &sizes)];
|
|
106
|
+
let res = validate_store_host(&hosts(), &entries);
|
|
107
|
+
assert_eq!(res.warning_count(), 1);
|
|
108
|
+
assert!(res.is_valid());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[test]
|
|
112
|
+
fn same_host_passes() {
|
|
113
|
+
let sizes = json!([{
|
|
114
|
+
"filament_weight": 1000, "diameter": 1.75,
|
|
115
|
+
"purchase_links": [{ "store_id": "polymaker", "url": "https://us.polymaker.com/products/x" }]
|
|
116
|
+
}]);
|
|
117
|
+
let entries = vec![("p", &sizes)];
|
|
118
|
+
assert_eq!(validate_store_host(&hosts(), &entries).warning_count(), 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#[test]
|
|
122
|
+
fn cross_domain_is_left_alone() {
|
|
123
|
+
let sizes = json!([{
|
|
124
|
+
"filament_weight": 1000, "diameter": 1.75,
|
|
125
|
+
"purchase_links": [{ "store_id": "polymaker", "url": "https://www.amazon.com/dp/x" }]
|
|
126
|
+
}]);
|
|
127
|
+
let entries = vec![("p", &sizes)];
|
|
128
|
+
assert_eq!(validate_store_host(&hosts(), &entries).warning_count(), 0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#[test]
|
|
132
|
+
fn builds_hosts_from_store_entries() {
|
|
133
|
+
let store = json!({ "id": "polymaker", "storefront_url": "https://us.polymaker.com/" });
|
|
134
|
+
let entries = vec![("stores/polymaker/store.json".to_string(), "store".to_string(), store)];
|
|
135
|
+
let map = build_store_hosts(&entries);
|
|
136
|
+
assert_eq!(map.get("polymaker").map(|s| s.as_str()), Some("us.polymaker.com"));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
use serde_json::Value;
|
|
2
|
+
use std::collections::HashSet;
|
|
3
|
+
use std::sync::LazyLock;
|
|
4
|
+
|
|
5
|
+
use crate::types::{ValidationError, ValidationResult};
|
|
6
|
+
|
|
7
|
+
/// Known tracking query-parameter keys (compared case-insensitively). Prefix families
|
|
8
|
+
/// (`utm_`, `mc_`, `pk_`) are handled separately in `is_tracking_key`.
|
|
9
|
+
///
|
|
10
|
+
/// Note: Shopify product selectors `variant` and `id` are intentionally NOT included —
|
|
11
|
+
/// they can point at a specific colour/product, so we treat them as meaningful.
|
|
12
|
+
static TRACKING_PARAMS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
|
13
|
+
[
|
|
14
|
+
"tag", "linkcode", "psc", "th", "ref", "ref_", "fbclid", "gclid", "gclsrc", "dclid",
|
|
15
|
+
"msclkid", "yclid", "twclid", "ttclid", "igshid", "si", "mc_cid", "mc_eid", "_pos", "_psq",
|
|
16
|
+
"_ss", "_v", "_sid", "spm", "scm", "aff", "affid", "srsltid", "gad_source", "epik",
|
|
17
|
+
"pk_campaign", "pk_kwd",
|
|
18
|
+
// Amazon / marketplace search-result context (identity lives in the /dp/<ASIN> path)
|
|
19
|
+
"dib", "dib_tag", "keywords", "qid", "sr", "sprefix", "crid", "ascsubtag", "smid",
|
|
20
|
+
"content-id", "qsid", "rnid", "refinements", "_encoding", "pldnsite",
|
|
21
|
+
]
|
|
22
|
+
.into_iter()
|
|
23
|
+
.collect()
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
fn is_tracking_key(key: &str) -> bool {
|
|
27
|
+
let k = key.trim().to_ascii_lowercase();
|
|
28
|
+
k.starts_with("utm_")
|
|
29
|
+
|| k.starts_with("mc_")
|
|
30
|
+
|| k.starts_with("pk_")
|
|
31
|
+
|| k.starts_with("pd_rd_")
|
|
32
|
+
|| k.starts_with("pf_rd_")
|
|
33
|
+
|| TRACKING_PARAMS.contains(k.as_str())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Hosts where the product identity is entirely in the path, so every query param is
|
|
37
|
+
/// search/tracking context and can be dropped wholesale (e.g. Amazon `/dp/<ASIN>`).
|
|
38
|
+
fn has_disposable_query(base: &str) -> bool {
|
|
39
|
+
let host = match crate::util::url_host(base) {
|
|
40
|
+
Some(h) => h.to_ascii_lowercase(),
|
|
41
|
+
None => return false,
|
|
42
|
+
};
|
|
43
|
+
if host == "a.co" || host == "amzn.to" || host == "amzn.eu" {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
["amazon", "ebay"]
|
|
47
|
+
.iter()
|
|
48
|
+
.any(|b| host.starts_with(&format!("{b}.")) || host.contains(&format!(".{b}.")))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Whether a URL fragment looks like tracking data (e.g. `#utm_source=x`) rather than a
|
|
52
|
+
/// content anchor (e.g. `#productinfo`, which we keep).
|
|
53
|
+
fn is_tracking_fragment(fragment: &str) -> bool {
|
|
54
|
+
if !fragment.contains('=') {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
let first_key = fragment.split(['&', '=']).next().unwrap_or("");
|
|
58
|
+
is_tracking_key(first_key)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Remove known tracking parameters (plus an empty `?` and tracking-only fragments) from a
|
|
62
|
+
/// URL, preserving the original ordering/encoding of everything kept. Idempotent.
|
|
63
|
+
pub fn strip_tracking(url: &str) -> String {
|
|
64
|
+
let (before_frag, fragment) = match url.split_once('#') {
|
|
65
|
+
Some((b, f)) => (b, Some(f)),
|
|
66
|
+
None => (url, None),
|
|
67
|
+
};
|
|
68
|
+
let (base, query) = match before_frag.split_once('?') {
|
|
69
|
+
Some((b, q)) => (b, Some(q)),
|
|
70
|
+
None => (before_frag, None),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let mut out = String::from(base);
|
|
74
|
+
|
|
75
|
+
if let Some(q) = query {
|
|
76
|
+
if !has_disposable_query(base) {
|
|
77
|
+
let kept: Vec<&str> = q
|
|
78
|
+
.split('&')
|
|
79
|
+
.filter(|pair| {
|
|
80
|
+
if pair.is_empty() {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
let key = pair.split('=').next().unwrap_or("");
|
|
84
|
+
!is_tracking_key(key)
|
|
85
|
+
})
|
|
86
|
+
.collect();
|
|
87
|
+
if !kept.is_empty() {
|
|
88
|
+
out.push('?');
|
|
89
|
+
out.push_str(&kept.join("&"));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if let Some(frag) = fragment {
|
|
95
|
+
if !frag.is_empty() && !is_tracking_fragment(frag) {
|
|
96
|
+
out.push('#');
|
|
97
|
+
out.push_str(frag);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// True if stripping tracking data would change the URL. Mirrors the webui `hasTracking`
|
|
105
|
+
/// helper; retained as a public API for consumers even though the rule uses `strip_tracking`
|
|
106
|
+
/// directly.
|
|
107
|
+
#[allow(dead_code)]
|
|
108
|
+
pub fn has_tracking(url: &str) -> bool {
|
|
109
|
+
strip_tracking(url) != url
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Warn about tracking parameters in `purchase_links[].url` inside sizes.json entries.
|
|
113
|
+
/// Each entry is (path_label, parsed sizes.json Value). Emits non-blocking warnings that
|
|
114
|
+
/// suggest the cleaned URL in the message.
|
|
115
|
+
pub fn validate_url_tracking(sizes_entries: &[(&str, &Value)]) -> ValidationResult {
|
|
116
|
+
let mut result = ValidationResult::default();
|
|
117
|
+
|
|
118
|
+
for (path_str, sizes_data) in sizes_entries {
|
|
119
|
+
let sizes_arr = match sizes_data.as_array() {
|
|
120
|
+
Some(a) => a,
|
|
121
|
+
None => continue,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
for (size_idx, size) in sizes_arr.iter().enumerate() {
|
|
125
|
+
let purchase_links = match size.get("purchase_links").and_then(|v| v.as_array()) {
|
|
126
|
+
Some(links) => links,
|
|
127
|
+
None => continue,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
for (link_idx, link) in purchase_links.iter().enumerate() {
|
|
131
|
+
if let Some(url) = link.get("url").and_then(|v| v.as_str()) {
|
|
132
|
+
let cleaned = strip_tracking(url);
|
|
133
|
+
if cleaned != url {
|
|
134
|
+
result.add(ValidationError::warning(
|
|
135
|
+
"URLTracking",
|
|
136
|
+
format!(
|
|
137
|
+
"Tracking parameters in url at $[{}].purchase_links[{}]; suggested: {}",
|
|
138
|
+
size_idx, link_idx, cleaned
|
|
139
|
+
),
|
|
140
|
+
Some(path_str.to_string()),
|
|
141
|
+
));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
result
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[cfg(test)]
|
|
152
|
+
mod tests {
|
|
153
|
+
use super::*;
|
|
154
|
+
use serde_json::json;
|
|
155
|
+
|
|
156
|
+
#[test]
|
|
157
|
+
fn strips_amazon_affiliate() {
|
|
158
|
+
assert_eq!(
|
|
159
|
+
strip_tracking("https://www.amazon.eg/dp/B0GVSM88NP/?tag=3dfil-20&linkCode=ogi&psc=1&th=1"),
|
|
160
|
+
"https://www.amazon.eg/dp/B0GVSM88NP/"
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#[test]
|
|
165
|
+
fn strips_amazon_search_query_wholesale() {
|
|
166
|
+
let dirty = "https://www.amazon.com/AmeLeis-Printer-Filament-Bundle-1-75mm/dp/B0GYNWLN2F?dib=eyJ2IjoiMSJ9.abc&dib_tag=se&keywords=AmeLeis&qid=1784042998&sr=8-1";
|
|
167
|
+
assert_eq!(
|
|
168
|
+
strip_tracking(dirty),
|
|
169
|
+
"https://www.amazon.com/AmeLeis-Printer-Filament-Bundle-1-75mm/dp/B0GYNWLN2F"
|
|
170
|
+
);
|
|
171
|
+
assert!(has_tracking(dirty));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#[test]
|
|
175
|
+
fn keeps_shopify_variant_selector() {
|
|
176
|
+
let u = "https://shop.polymaker.com/products/panchroma-galaxy?variant=44933539987513";
|
|
177
|
+
assert_eq!(strip_tracking(u), u);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#[test]
|
|
181
|
+
fn keeps_variant_and_content_fragment() {
|
|
182
|
+
let u = "https://shop.polymaker.com/products/panchroma-pla?variant=45411122413625#productinfo";
|
|
183
|
+
assert_eq!(strip_tracking(u), u);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#[test]
|
|
187
|
+
fn removes_trailing_empty_query() {
|
|
188
|
+
assert_eq!(
|
|
189
|
+
strip_tracking("https://us.polymaker.com/products/panchroma-glow?"),
|
|
190
|
+
"https://us.polymaker.com/products/panchroma-glow"
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#[test]
|
|
195
|
+
fn removes_trackers_but_keeps_meaningful() {
|
|
196
|
+
assert_eq!(
|
|
197
|
+
strip_tracking("https://x.com/p?utm_source=a&variant=5&gclid=z"),
|
|
198
|
+
"https://x.com/p?variant=5"
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#[test]
|
|
203
|
+
fn strips_tracking_fragment_keeps_anchor() {
|
|
204
|
+
assert_eq!(strip_tracking("https://x.com/p#utm_source=a"), "https://x.com/p");
|
|
205
|
+
assert_eq!(strip_tracking("https://x.com/p#section-2"), "https://x.com/p#section-2");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
#[test]
|
|
209
|
+
fn is_idempotent() {
|
|
210
|
+
let dirty = "https://www.amazon.eg/dp/B0GVSM88NP/?tag=3dfil-20&th=1";
|
|
211
|
+
let once = strip_tracking(dirty);
|
|
212
|
+
assert_eq!(strip_tracking(&once), once);
|
|
213
|
+
assert!(has_tracking(dirty));
|
|
214
|
+
assert!(!has_tracking(&once));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#[test]
|
|
218
|
+
fn rule_emits_warning_not_error() {
|
|
219
|
+
let sizes = json!([{
|
|
220
|
+
"filament_weight": 1000, "diameter": 1.75,
|
|
221
|
+
"purchase_links": [{ "store_id": "amazon", "url": "https://a.com/x?tag=aff-20" }]
|
|
222
|
+
}]);
|
|
223
|
+
let entries = vec![("data/b/PLA/f/red/sizes.json", &sizes)];
|
|
224
|
+
let res = validate_url_tracking(&entries);
|
|
225
|
+
assert_eq!(res.warning_count(), 1);
|
|
226
|
+
assert_eq!(res.error_count(), 0);
|
|
227
|
+
assert!(res.is_valid()); // warnings never invalidate
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
use serde_json::Value;
|
|
2
|
+
use std::collections::HashSet;
|
|
3
|
+
|
|
4
|
+
use crate::types::{ValidationError, ValidationResult};
|
|
5
|
+
|
|
6
|
+
/// Canonical material-type tokens (mirrors `schemas/material_types_schema.json`).
|
|
7
|
+
/// Used as a fallback; `validate_dataset` prefers the live schema enum.
|
|
8
|
+
pub const MATERIAL_TYPES_FALLBACK: &[&str] = &[
|
|
9
|
+
"PLA", "PETG", "TPU", "ABS", "ASA", "PC", "PCTG", "PP", "PA6", "PA11", "PA12", "PA66", "CPE",
|
|
10
|
+
"TPE", "HIPS", "PHA", "PET", "PEI", "PBT", "PVB", "PVA", "PEKK", "PEEK", "BVOH", "TPC", "PPS",
|
|
11
|
+
"PPSU", "PVC", "PEBA", "PVDF", "PPA", "PCL", "PES", "PMMA", "POM", "PPE", "PS", "PSU", "TPI",
|
|
12
|
+
"SBS", "OBC", "EVA",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/// Split a display name / id into upper-cased alphanumeric tokens.
|
|
16
|
+
fn tokens(s: &str) -> Vec<String> {
|
|
17
|
+
s.split(|c: char| !c.is_ascii_alphanumeric())
|
|
18
|
+
.filter(|t| !t.is_empty())
|
|
19
|
+
.map(|t| t.to_ascii_uppercase())
|
|
20
|
+
.collect()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Return the first material token embedded in `name`, if any (whole-token match).
|
|
24
|
+
fn find_material_token(name: &str, material_types: &HashSet<String>) -> Option<String> {
|
|
25
|
+
tokens(name).into_iter().find(|t| material_types.contains(t))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Extract the variant folder name from a `.../<variant>/variant.json` path label.
|
|
29
|
+
fn variant_folder(path: &str) -> Option<String> {
|
|
30
|
+
let norm = path.replace('\\', "/");
|
|
31
|
+
let mut parts = norm.rsplit('/');
|
|
32
|
+
let _file = parts.next()?; // variant.json
|
|
33
|
+
parts.next().map(|s| s.to_string())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Warn when a variant's colour name (or its folder id) embeds a material-type token,
|
|
37
|
+
/// which is redundant with the parent MATERIAL directory. Non-blocking warnings;
|
|
38
|
+
/// at most one per variant. `variant_entries` are (path_label, parsed variant.json Value).
|
|
39
|
+
pub fn validate_variant_material(
|
|
40
|
+
variant_entries: &[(&str, &Value)],
|
|
41
|
+
material_types: &HashSet<String>,
|
|
42
|
+
) -> ValidationResult {
|
|
43
|
+
let mut result = ValidationResult::default();
|
|
44
|
+
|
|
45
|
+
for (path_str, data) in variant_entries {
|
|
46
|
+
if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
|
|
47
|
+
if let Some(tok) = find_material_token(name, material_types) {
|
|
48
|
+
result.add(ValidationError::warning(
|
|
49
|
+
"VariantMaterial",
|
|
50
|
+
format!(
|
|
51
|
+
"Colour name '{}' contains material type '{}', which is redundant with the material folder",
|
|
52
|
+
name, tok
|
|
53
|
+
),
|
|
54
|
+
Some(path_str.to_string()),
|
|
55
|
+
));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if let Some(folder) = variant_folder(path_str) {
|
|
61
|
+
if let Some(tok) = find_material_token(&folder, material_types) {
|
|
62
|
+
result.add(ValidationError::warning(
|
|
63
|
+
"VariantMaterial",
|
|
64
|
+
format!(
|
|
65
|
+
"Variant folder id '{}' contains material type '{}', which is redundant with the material folder",
|
|
66
|
+
folder, tok
|
|
67
|
+
),
|
|
68
|
+
Some(path_str.to_string()),
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#[cfg(test)]
|
|
78
|
+
mod tests {
|
|
79
|
+
use super::*;
|
|
80
|
+
use serde_json::json;
|
|
81
|
+
|
|
82
|
+
fn material_set() -> HashSet<String> {
|
|
83
|
+
MATERIAL_TYPES_FALLBACK.iter().map(|s| s.to_string()).collect()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#[test]
|
|
87
|
+
fn flags_material_in_colour_name() {
|
|
88
|
+
let v = json!({ "name": "Galaxy PETG", "color_hex": "#112233" });
|
|
89
|
+
let entries = vec![("data/b/PETG/f/galaxy_petg/variant.json", &v)];
|
|
90
|
+
let res = validate_variant_material(&entries, &material_set());
|
|
91
|
+
assert_eq!(res.warning_count(), 1);
|
|
92
|
+
assert_eq!(res.error_count(), 0);
|
|
93
|
+
assert!(res.is_valid());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#[test]
|
|
97
|
+
fn flags_material_in_folder_id_when_name_clean() {
|
|
98
|
+
let v = json!({ "name": "Sakura Pink", "color_hex": "#ffaabb" });
|
|
99
|
+
let entries = vec![("data/sunlu/PETG/petg/sakura_pink_petg/variant.json", &v)];
|
|
100
|
+
let res = validate_variant_material(&entries, &material_set());
|
|
101
|
+
assert_eq!(res.warning_count(), 1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[test]
|
|
105
|
+
fn clean_name_and_folder_pass() {
|
|
106
|
+
let v = json!({ "name": "Sakura Pink", "color_hex": "#ffaabb" });
|
|
107
|
+
let entries = vec![("data/sunlu/PETG/petg/sakura_pink/variant.json", &v)];
|
|
108
|
+
let res = validate_variant_material(&entries, &material_set());
|
|
109
|
+
assert_eq!(res.warning_count(), 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#[test]
|
|
113
|
+
fn whole_token_match_avoids_substrings() {
|
|
114
|
+
// "PETAL" must not match "PET"; "Space" must not match "PA"/"AS".
|
|
115
|
+
let v = json!({ "name": "Petal Space Grey", "color_hex": "#777777" });
|
|
116
|
+
let entries = vec![("data/b/PLA/f/petal/variant.json", &v)];
|
|
117
|
+
let res = validate_variant_material(&entries, &material_set());
|
|
118
|
+
assert_eq!(res.warning_count(), 0);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -6,7 +6,8 @@ mod util;
|
|
|
6
6
|
mod validators;
|
|
7
7
|
|
|
8
8
|
use orchestrator::{
|
|
9
|
-
validate_all, validate_folder_names, validate_json_files,
|
|
9
|
+
validate_all, validate_all_with_changes, validate_folder_names, validate_json_files,
|
|
10
|
+
validate_logo_files,
|
|
10
11
|
};
|
|
11
12
|
use types::{ValidationError, ValidationLevel, ValidationResult};
|
|
12
13
|
use validators::{
|
|
@@ -22,6 +23,7 @@ fn ofd_validator(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
22
23
|
|
|
23
24
|
// Orchestrated batch validators (internally parallel with rayon)
|
|
24
25
|
m.add_function(wrap_pyfunction!(validate_all, m)?)?;
|
|
26
|
+
m.add_function(wrap_pyfunction!(validate_all_with_changes, m)?)?;
|
|
25
27
|
m.add_function(wrap_pyfunction!(validate_json_files, m)?)?;
|
|
26
28
|
m.add_function(wrap_pyfunction!(validate_logo_files, m)?)?;
|
|
27
29
|
m.add_function(wrap_pyfunction!(validate_folder_names, m)?)?;
|
|
@@ -57,6 +57,41 @@ pub fn validate_all(
|
|
|
57
57
|
})
|
|
58
58
|
}
|
|
59
59
|
|
|
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
|
+
|
|
60
95
|
#[pyfunction]
|
|
61
96
|
#[pyo3(signature = (data_dir, stores_dir, schemas_dir=None, max_workers=None))]
|
|
62
97
|
pub fn validate_json_files(
|
ofd_validator-0.5.0/Cargo.toml
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/folder_name.rs
RENAMED
|
File without changes
|
{ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/gtin.rs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/store_id.rs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|