Skip to main content

document_svg/document/
ocel.rs

1//! Bounded OCEL 2.0 JSON object-centric event-log previews.
2//!
3//! OCEL 2.0 stores event types, events, object types and objects together with
4//! event-to-object and object-to-object relationships. This adapter renders
5//! identifiers, types, timestamps and counts only; attribute values, qualifiers
6//! and external references remain inert and are never evaluated or fetched.
7
8use serde_json::Value;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::table::{TableAlign, TableData};
15
16const MAX_OCEL_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_OCEL_DEPTH: usize = 100;
18const MAX_OCEL_VALUES: usize = 300_000;
19const MAX_OCEL_ROWS: usize = 200_000;
20const MAX_OCEL_NODES: usize = 100_000;
21const MAX_OCEL_STRING_BYTES: usize = 2 * 1024 * 1024;
22const MAX_OCEL_DISPLAY_BYTES: usize = 512;
23const ATTRIBUTE_TYPES: &[&str] = &["string", "time", "integer", "float", "boolean"];
24
25pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
26    let text = String::from_utf8_lossy(prefix);
27    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
28    if !trimmed.starts_with('{') {
29        return false;
30    }
31    if let Ok(Value::Object(object)) = serde_json::from_str::<Value>(trimmed) {
32        return ["eventTypes", "events", "objectTypes", "objects"]
33            .iter()
34            .all(|key| object.get(*key).and_then(Value::as_array).is_some());
35    }
36    ["eventTypes", "events", "objectTypes", "objects"]
37        .iter()
38        .all(|key| text.contains(&format!("\"{key}\"")))
39}
40
41struct OcelPageSink<'a> {
42    inner: &'a mut dyn PageConsumer,
43    warnings: &'a [String],
44}
45
46impl PageConsumer for OcelPageSink<'_> {
47    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
48        page.source_format = "ocel-json".into();
49        if page.title.is_empty() {
50            page.title = "OCEL 2.0 JSON".into();
51        }
52        page.description =
53            "OCEL 2.0 event and object metadata is rendered inertly; attribute values, qualifiers and external references are not displayed or resolved".into();
54        for warning in self.warnings {
55            page.warn(warning.clone());
56        }
57        self.inner.consume(page)
58    }
59}
60
61pub(crate) fn convert(
62    path: &Path,
63    options: &ConvertOptions,
64    sink: &mut dyn PageConsumer,
65) -> Result<Vec<String>> {
66    let bytes = read_limited_file(
67        path,
68        options.max_input_bytes.min(MAX_OCEL_BYTES),
69        "OCEL JSON input",
70    )?;
71    let text = String::from_utf8(bytes)
72        .map_err(|error| Error::InvalidInput(format!("OCEL JSON must be UTF-8 JSON: {error}")))?;
73    let (table, metadata, warnings) = parse(&text)?;
74    let blocks = vec![
75        HtmlBlock::Heading {
76            level: 1,
77            text: "OCEL 2.0 JSON".into(),
78        },
79        HtmlBlock::Paragraph { text: metadata },
80        HtmlBlock::Table(table),
81    ];
82    let mut page_sink = OcelPageSink {
83        inner: sink,
84        warnings: &warnings,
85    };
86    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
87    Ok(warnings)
88}
89
90#[derive(Default)]
91struct Summary {
92    rows: Vec<Vec<String>>,
93    event_types: usize,
94    object_types: usize,
95    events: usize,
96    objects: usize,
97    event_attributes: usize,
98    object_attributes: usize,
99    event_relationships: usize,
100    object_relationships: usize,
101}
102
103fn parse(text: &str) -> Result<(TableData, String, Vec<String>)> {
104    if text.len() as u64 > MAX_OCEL_BYTES {
105        return Err(Error::LimitExceeded(format!(
106            "OCEL JSON exceeds {MAX_OCEL_BYTES} bytes"
107        )));
108    }
109    preflight_depth(text)?;
110    let value: Value = serde_json::from_str(text)
111        .map_err(|error| Error::InvalidInput(format!("invalid OCEL JSON: {error}")))?;
112    let mut values = 0usize;
113    count_values(&value, 0, &mut values)?;
114    let root = value
115        .as_object()
116        .ok_or_else(|| Error::InvalidInput("OCEL JSON root must be an object".into()))?;
117    let event_types = required_array(root, "eventTypes")?;
118    let events = required_array(root, "events")?;
119    let object_types = required_array(root, "objectTypes")?;
120    let objects = required_array(root, "objects")?;
121    if event_types
122        .len()
123        .saturating_add(events.len())
124        .saturating_add(object_types.len())
125        .saturating_add(objects.len())
126        > MAX_OCEL_NODES
127    {
128        return Err(Error::LimitExceeded(format!(
129            "OCEL nodes exceed {MAX_OCEL_NODES}"
130        )));
131    }
132    let mut summary = Summary::default();
133    let mut warnings = Vec::new();
134    for (index, event_type) in event_types.iter().enumerate() {
135        let object = event_type.as_object().ok_or_else(|| {
136            Error::InvalidInput(format!(
137                "OCEL eventTypes item {} must be an object",
138                index + 1
139            ))
140        })?;
141        let name = required_string(object, "name", "event type", index)?;
142        let attrs = definition_attributes(object, "event type", index)?;
143        summary.event_types = summary.event_types.saturating_add(1);
144        push_row(
145            &mut summary,
146            vec![
147                "event-type".into(),
148                truncate(name),
149                "—".into(),
150                format!("{attrs} attrs"),
151                "—".into(),
152            ],
153        )?;
154    }
155    for (index, object_type) in object_types.iter().enumerate() {
156        let object = object_type.as_object().ok_or_else(|| {
157            Error::InvalidInput(format!(
158                "OCEL objectTypes item {} must be an object",
159                index + 1
160            ))
161        })?;
162        let name = required_string(object, "name", "object type", index)?;
163        let attrs = definition_attributes(object, "object type", index)?;
164        summary.object_types = summary.object_types.saturating_add(1);
165        push_row(
166            &mut summary,
167            vec![
168                "object-type".into(),
169                truncate(name),
170                "—".into(),
171                format!("{attrs} attrs"),
172                "—".into(),
173            ],
174        )?;
175    }
176    for (index, event) in events.iter().enumerate() {
177        let object = event.as_object().ok_or_else(|| {
178            Error::InvalidInput(format!("OCEL events item {} must be an object", index + 1))
179        })?;
180        let id = required_string(object, "id", "event", index)?;
181        let event_type = required_string(object, "type", "event", index)?;
182        let time = required_string(object, "time", "event", index)?;
183        let attrs = attribute_array(object, "attributes", "event", index)?;
184        let relationships = relationship_array(object, "relationships", "event", index)?;
185        for relationship in relationships {
186            validate_relationship(relationship, "event relationship", index)?;
187        }
188        summary.events = summary.events.saturating_add(1);
189        summary.event_attributes = summary.event_attributes.saturating_add(attrs.len());
190        summary.event_relationships = summary
191            .event_relationships
192            .saturating_add(relationships.len());
193        push_row(
194            &mut summary,
195            vec![
196                "event".into(),
197                truncate(id),
198                truncate(event_type),
199                truncate(time),
200                format!("attrs {} · objects {}", attrs.len(), relationships.len()),
201            ],
202        )?;
203    }
204    for (index, item) in objects.iter().enumerate() {
205        let object = item.as_object().ok_or_else(|| {
206            Error::InvalidInput(format!("OCEL objects item {} must be an object", index + 1))
207        })?;
208        let id = required_string(object, "id", "object", index)?;
209        let object_type = required_string(object, "type", "object", index)?;
210        let attrs = attribute_array(object, "attributes", "object", index)?;
211        let relationships = relationship_array(object, "relationships", "object", index)?;
212        for relationship in relationships {
213            validate_relationship(relationship, "object relationship", index)?;
214        }
215        summary.objects = summary.objects.saturating_add(1);
216        summary.object_attributes = summary.object_attributes.saturating_add(attrs.len());
217        summary.object_relationships = summary
218            .object_relationships
219            .saturating_add(relationships.len());
220        push_row(
221            &mut summary,
222            vec![
223                "object".into(),
224                truncate(id),
225                truncate(object_type),
226                format!("{} attrs", attrs.len()),
227                format!("objects {}", relationships.len()),
228            ],
229        )?;
230    }
231    if summary.rows.is_empty() {
232        return Err(Error::InvalidInput(
233            "OCEL JSON contains no event, object or type rows".into(),
234        ));
235    }
236    let metadata = format!(
237        "Event types: {}\nObject types: {}\nEvents: {}\nObjects: {}\nEvent attributes: {}\nObject attributes: {}\nEvent→object relationships: {}\nObject→object relationships: {}",
238        summary.event_types,
239        summary.object_types,
240        summary.events,
241        summary.objects,
242        summary.event_attributes,
243        summary.object_attributes,
244        summary.event_relationships,
245        summary.object_relationships
246    );
247    warnings.push("OCEL attribute values, qualifiers, object IDs in relationship payloads, timestamps beyond their inert text, URLs and external resources are omitted; no process-mining discovery, filtering or execution runs".into());
248    warnings.push("OCEL 2.0 JSON arrays and relationship counts are bounded; event/object type names are shown as labels without dereferencing or semantic evaluation".into());
249    Ok((
250        TableData {
251            headers: vec![
252                "Kind".into(),
253                "ID / name".into(),
254                "Type".into(),
255                "Time / attrs".into(),
256                "Relations".into(),
257            ],
258            rows: summary.rows,
259            alignments: vec![TableAlign::Left; 5],
260            raw_source: String::new(),
261        },
262        metadata,
263        warnings,
264    ))
265}
266
267fn required_array<'a>(root: &'a serde_json::Map<String, Value>, key: &str) -> Result<&'a [Value]> {
268    root.get(key)
269        .and_then(Value::as_array)
270        .map(Vec::as_slice)
271        .ok_or_else(|| Error::InvalidInput(format!("OCEL JSON requires {key} array")))
272}
273
274fn required_string<'a>(
275    object: &'a serde_json::Map<String, Value>,
276    key: &str,
277    kind: &str,
278    index: usize,
279) -> Result<&'a str> {
280    let value = object.get(key).and_then(Value::as_str).ok_or_else(|| {
281        Error::InvalidInput(format!("OCEL {kind} {} requires string {key}", index + 1))
282    })?;
283    if value.is_empty() {
284        return Err(Error::InvalidInput(format!(
285            "OCEL {kind} {} {key} must not be empty",
286            index + 1
287        )));
288    }
289    Ok(value)
290}
291
292fn definition_attributes(
293    object: &serde_json::Map<String, Value>,
294    kind: &str,
295    index: usize,
296) -> Result<usize> {
297    let Some(attributes) = object.get("attributes") else {
298        return Ok(0);
299    };
300    let attributes = attributes.as_array().ok_or_else(|| {
301        Error::InvalidInput(format!(
302            "OCEL {kind} {} attributes must be an array",
303            index + 1
304        ))
305    })?;
306    for attribute in attributes {
307        let attribute = attribute.as_object().ok_or_else(|| {
308            Error::InvalidInput(format!(
309                "OCEL {kind} {} attribute must be an object",
310                index + 1
311            ))
312        })?;
313        required_string(attribute, "name", "attribute", index)?;
314        let value_type = required_string(attribute, "type", "attribute", index)?;
315        if !ATTRIBUTE_TYPES.contains(&value_type) {
316            return Err(Error::Unsupported(format!(
317                "OCEL attribute type '{value_type}' is unsupported"
318            )));
319        }
320    }
321    Ok(attributes.len())
322}
323
324fn attribute_array<'a>(
325    object: &'a serde_json::Map<String, Value>,
326    key: &str,
327    kind: &str,
328    index: usize,
329) -> Result<&'a [Value]> {
330    object.get(key).map_or(Ok(&[]), |value| {
331        value.as_array().map(Vec::as_slice).ok_or_else(|| {
332            Error::InvalidInput(format!("OCEL {kind} {} {key} must be an array", index + 1))
333        })
334    })
335}
336
337fn relationship_array<'a>(
338    object: &'a serde_json::Map<String, Value>,
339    key: &str,
340    kind: &str,
341    index: usize,
342) -> Result<&'a [Value]> {
343    object.get(key).map_or(Ok(&[]), |value| {
344        value.as_array().map(Vec::as_slice).ok_or_else(|| {
345            Error::InvalidInput(format!("OCEL {kind} {} {key} must be an array", index + 1))
346        })
347    })
348}
349
350fn validate_relationship(value: &Value, kind: &str, index: usize) -> Result<()> {
351    let object = value.as_object().ok_or_else(|| {
352        Error::InvalidInput(format!("OCEL {kind} {} must be an object", index + 1))
353    })?;
354    required_string(object, "objectId", kind, index)?;
355    Ok(())
356}
357
358fn push_row(summary: &mut Summary, row: Vec<String>) -> Result<()> {
359    if summary.rows.len() >= MAX_OCEL_ROWS {
360        return Err(Error::LimitExceeded(format!(
361            "OCEL rows exceed {MAX_OCEL_ROWS}"
362        )));
363    }
364    summary.rows.push(row);
365    Ok(())
366}
367
368fn truncate(value: &str) -> String {
369    if value.len() <= MAX_OCEL_DISPLAY_BYTES {
370        return value.to_owned();
371    }
372    let mut end = MAX_OCEL_DISPLAY_BYTES;
373    while !value.is_char_boundary(end) {
374        end -= 1;
375    }
376    format!("{}…", &value[..end])
377}
378
379fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
380    if depth > MAX_OCEL_DEPTH {
381        return Err(Error::LimitExceeded(format!(
382            "OCEL JSON nesting exceeds {MAX_OCEL_DEPTH} levels"
383        )));
384    }
385    *count = count.saturating_add(1);
386    if *count > MAX_OCEL_VALUES {
387        return Err(Error::LimitExceeded(format!(
388            "OCEL JSON contains more than {MAX_OCEL_VALUES} values"
389        )));
390    }
391    match value {
392        Value::Array(values) => {
393            for item in values {
394                count_values(item, depth + 1, count)?;
395            }
396        }
397        Value::Object(map) => {
398            for item in map.values() {
399                count_values(item, depth + 1, count)?;
400            }
401        }
402        Value::String(value) if value.len() > MAX_OCEL_STRING_BYTES => {
403            return Err(Error::LimitExceeded(format!(
404                "OCEL JSON string exceeds {MAX_OCEL_STRING_BYTES} bytes"
405            )));
406        }
407        _ => {}
408    }
409    Ok(())
410}
411
412fn preflight_depth(text: &str) -> Result<()> {
413    let mut depth = 0usize;
414    let mut quoted = false;
415    let mut escaped = false;
416    for byte in text.bytes() {
417        if quoted {
418            if escaped {
419                escaped = false;
420            } else if byte == b'\\' {
421                escaped = true;
422            } else if byte == b'"' {
423                quoted = false;
424            }
425            continue;
426        }
427        match byte {
428            b'"' => quoted = true,
429            b'{' | b'[' => {
430                depth += 1;
431                if depth > MAX_OCEL_DEPTH {
432                    return Err(Error::LimitExceeded(format!(
433                        "OCEL JSON nesting exceeds {MAX_OCEL_DEPTH} levels"
434                    )));
435                }
436            }
437            b'}' | b']' => depth = depth.saturating_sub(1),
438            _ => {}
439        }
440    }
441    Ok(())
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn recognizes_ocel_root() {
450        assert!(looks_like_prefix(
451            br#"{"eventTypes":[],"events":[],"objectTypes":[],"objects":[]}"#
452        ));
453        assert!(!looks_like_prefix(br#"{"events":[]}"#));
454    }
455
456    #[test]
457    fn summarizes_event_object_relationships_without_values() {
458        let (table, metadata, warnings) = parse(
459            r#"{"eventTypes":[{"name":"create-order","attributes":[{"name":"total","type":"integer"}]}],"objectTypes":[{"name":"order","attributes":[{"name":"secret","type":"string"}]}],"events":[{"id":"e1","type":"create-order","time":"2024-01-01T00:00:00Z","attributes":[{"name":"total","value":7}],"relationships":[{"objectId":"o1","qualifier":"item"}]}],"objects":[{"id":"o1","type":"order","attributes":[{"name":"secret","time":"2024-01-01T00:00:00Z","value":"very-secret"}]}]}"#,
460        ).unwrap();
461        assert_eq!(table.rows.len(), 4);
462        assert!(metadata.contains("Event→object relationships: 1"));
463        assert!(
464            !table
465                .rows
466                .iter()
467                .flatten()
468                .any(|value| value.contains("very-secret") || value.contains("7"))
469        );
470        assert!(
471            warnings
472                .iter()
473                .any(|warning| warning.contains("attribute values"))
474        );
475    }
476}