From 6002f176fb93623dd1b3f027d70b5a2616db654b Mon Sep 17 00:00:00 2001 From: Jonathan Putney Date: Wed, 22 Jul 2026 08:36:55 -0400 Subject: [PATCH 1/2] Preserve imsss and adlseq objectives as separate containers Jackson XML binding collapsed same-local-name objectives elements, so an adlseq:objectives block overwrote the imsss:objectives container when both were present: the IMS primaryObjective (and its mapInfo targets) was silently lost and the ADL maps were bound through the IMS mapping type, dropping the score and progress flags. This broke SCORM 2004 packages that declare both containers for the same objectiveID (ADL CTS OB-10a among them). - New ADLObjectives/ADLObjective container models adlseq:objectives faithfully, with the full adlseq map flag set (raw/min/max score, completion, progress measure; schema defaults reads true, writes false). - Scorm2004Parser restores the IMS and ADL containers namespace-aware after the Jackson parse. - Sequencing.getAdlObjectives() returns the ADL container; the IMS primaryObjective survives with its own mapInfo. - Global objective ID collection is additive across both containers. Regression tests cover same-ID dual declarations, flag round-tripping, and schema defaults. --- .../input/scorm2004/Scorm2004Manifest.java | 40 +- .../scorm2004/SequencingUsageDetector.java | 11 + .../adl/sequencing/ADLObjectives.java | 97 +++++ .../ims/ss/sequencing/Sequencing.java | 15 +- .../metadata/scorm2004/Scorm2004Metadata.java | 39 +- .../parser/parsers/Scorm2004Parser.java | 362 ++++++++++++++++++ .../SequencingUsageDetectorTest.java | 10 +- .../Scorm2004ObjectiveParserTest.java | 207 ++++++++++ 8 files changed, 754 insertions(+), 27 deletions(-) create mode 100644 src/main/java/dev/jcputney/elearning/parser/input/scorm2004/adl/sequencing/ADLObjectives.java diff --git a/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/Scorm2004Manifest.java b/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/Scorm2004Manifest.java index 4ee4de78..3d1955e1 100644 --- a/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/Scorm2004Manifest.java +++ b/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/Scorm2004Manifest.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; import dev.jcputney.elearning.parser.input.PackageManifest; import dev.jcputney.elearning.parser.input.lom.LOM; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ADLObjective; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.MapInfo; import dev.jcputney.elearning.parser.input.scorm2004.adl.types.ScormType; import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004CourseMetadata; import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004Item; @@ -517,9 +519,9 @@ public Set getGlobalObjectiveIds() { .getOrganizationList() .stream() .flatMap(org -> safeStream(org.getItems())) // Null-safe stream for items - .flatMap(item -> safeStream(getObjectives(item))) // Null-safe stream for objectives - .flatMap(obj -> safeStream(obj.getMapInfo())) // Null-safe stream for mapInfo - .map(Scorm2004ObjectiveMapping::getTargetObjectiveID) + .flatMap(item -> Stream.concat( + getObjectiveMappings(item).map(Scorm2004ObjectiveMapping::getTargetObjectiveID), + getAdlObjectiveMappings(item).map(MapInfo::getTargetObjectiveID))) .filter(id -> id != null && !id.isEmpty()) // Filter non-null and non-empty IDs .collect(Collectors.toSet()); } @@ -866,16 +868,38 @@ public int hashCode() { * @param item The SCORM item to retrieve objectives from. * @return A list of objectives, or an empty list if null. */ - private List getObjectives(Scorm2004Item item) { + private Stream getObjectiveMappings(Scorm2004Item item) { if (item.getSequencing() != null && item .getSequencing() .getObjectives() != null) { - return item + var objectives = item .getSequencing() - .getObjectives() - .getObjectiveList(); + .getObjectives(); + return Stream + .concat(Stream.ofNullable(objectives.getPrimaryObjective()), + safeStream(objectives.getObjectiveList())) + .flatMap(objective -> safeStream(objective.getMapInfo())); } - return List.of(); // Return an empty list if objectives are null + return Stream.empty(); + } + + /** + * Retrieves ADL mapInfo entries from a given item in a null-safe manner. + * + * @param item The SCORM item to retrieve ADL objective mappings from. + * @return A stream of ADL mapInfo entries, or an empty stream if none exist. + */ + private Stream getAdlObjectiveMappings(Scorm2004Item item) { + if (item.getSequencing() != null && item + .getSequencing() + .getAdlObjectives() != null) { + return safeStream(item + .getSequencing() + .getAdlObjectives() + .getObjectiveList()) + .flatMap((ADLObjective objective) -> safeStream(objective.getMapInfoList())); + } + return Stream.empty(); } /** diff --git a/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/SequencingUsageDetector.java b/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/SequencingUsageDetector.java index 978ca855..eba183b3 100644 --- a/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/SequencingUsageDetector.java +++ b/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/SequencingUsageDetector.java @@ -14,6 +14,7 @@ import dev.jcputney.elearning.parser.input.scorm2004.adl.cp.CompletionThreshold; import dev.jcputney.elearning.parser.input.scorm2004.adl.navigation.NavigationInterface; import dev.jcputney.elearning.parser.input.scorm2004.adl.navigation.Presentation; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ADLObjectives; import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ConstrainChoiceConsiderations; import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.RollupConsiderations; import dev.jcputney.elearning.parser.input.scorm2004.adl.types.ScormType; @@ -567,6 +568,16 @@ private static boolean hasObjectives(Scorm2004Objectives objectives) { || isNotEmpty(objectives.getObjectiveList()); } + /** + * Determines if the provided ADL objectives object has objectives defined. + * + * @param objectives the ADL objectives object to inspect; may be null + * @return true if the ADL objectives object contains at least one objective + */ + private static boolean hasObjectives(ADLObjectives objectives) { + return objectives != null && isNotEmpty(objectives.getObjectiveList()); + } + /** * Determines if the provided RollupRules object has at least one significant rule or condition * defined. This method evaluates the list of rollup rules and other attributes within the diff --git a/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/adl/sequencing/ADLObjectives.java b/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/adl/sequencing/ADLObjectives.java new file mode 100644 index 00000000..a22859a8 --- /dev/null +++ b/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/adl/sequencing/ADLObjectives.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024-2026 Jonathan Putney + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at the project root LICENSE file + * or at http://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import dev.jcputney.elearning.parser.input.scorm2004.ADLSeq; +import java.io.Serializable; +import java.util.List; +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; + +/** + * Represents the ADL sequencing objectives container. + * + *

SCORM 2004 defines {@code adlseq:objectives} separately from IMS Simple Sequencing + * {@code imsss:objectives}. The ADL container contains one or more {@code adlseq:objective} + * elements whose {@code adlseq:mapInfo} children expose additional score, completion, and progress + * mapping flags.

+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES) +public final class ADLObjectives implements Serializable { + + /** + * ADL objectives in document order. + */ + @JacksonXmlElementWrapper(localName = "objective", useWrapping = false) + @JacksonXmlProperty(localName = "objective", namespace = ADLSeq.NAMESPACE_URI) + private List objectiveList; + + /** + * Default constructor for Jackson and callers that populate the container manually. + */ + public ADLObjectives() { + // no-op + } + + /** + * Constructs an ADL objectives container with the supplied objective list. + * + * @param objectiveList ADL objectives in document order + */ + public ADLObjectives(List objectiveList) { + this.objectiveList = objectiveList; + } + + /** + * Returns the ADL objectives in document order. + * + * @return ADL objectives, or null if none were parsed + */ + public List getObjectiveList() { + return this.objectiveList; + } + + /** + * Sets the ADL objectives in document order. + * + * @param objectiveList ADL objectives to assign + */ + public void setObjectiveList(List objectiveList) { + this.objectiveList = objectiveList; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof ADLObjectives that)) { + return false; + } + + return new EqualsBuilder() + .append(getObjectiveList(), that.getObjectiveList()) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(getObjectiveList()) + .toHashCode(); + } +} diff --git a/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/sequencing/Sequencing.java b/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/sequencing/Sequencing.java index edac1c61..c97fc7fd 100644 --- a/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/sequencing/Sequencing.java +++ b/src/main/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/sequencing/Sequencing.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; import dev.jcputney.elearning.parser.input.scorm2004.ADLSeq; import dev.jcputney.elearning.parser.input.scorm2004.IMSSS; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ADLObjectives; import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ConstrainChoiceConsiderations; import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.RollupConsiderations; import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.objective.Scorm2004Objectives; @@ -87,8 +88,7 @@ public final class Sequencing implements Serializable { * The ADL objectives for this sequencing element. */ @JacksonXmlProperty(localName = "objectives", namespace = ADLSeq.NAMESPACE_URI) - @JsonProperty("adlObjectives") - private Scorm2004Objectives adlObjectives; + private ADLObjectives adlObjectives; /** * Controls the randomization of child activities within a sequence. @@ -282,22 +282,21 @@ public void setObjectives(Scorm2004Objectives objectives) { /** * Retrieves the ADL-specific objectives associated with this sequencing object. * - * @return the ADL objectives as a {@code Scorm2004Objectives} object + * @return the ADL objectives as an {@code ADLObjectives} object */ @JsonProperty("adlObjectives") - public Scorm2004Objectives getAdlObjectives() { + public ADLObjectives getAdlObjectives() { return this.adlObjectives; } /** * Sets the ADL-specific objectives for the sequencing object. * - * @param adlObjectives the {@code Scorm2004Objectives} object representing the set of ADL - * objectives used to assess and track progress and performance within the SCORM 2004 sequencing - * model. + * @param adlObjectives the {@code ADLObjectives} object representing the set of ADL objectives + * used to assess and track progress and performance within the SCORM 2004 sequencing model. */ @JsonProperty("adlObjectives") - public void setAdlObjectives(Scorm2004Objectives adlObjectives) { + public void setAdlObjectives(ADLObjectives adlObjectives) { this.adlObjectives = adlObjectives; } diff --git a/src/main/java/dev/jcputney/elearning/parser/output/metadata/scorm2004/Scorm2004Metadata.java b/src/main/java/dev/jcputney/elearning/parser/output/metadata/scorm2004/Scorm2004Metadata.java index 3848ff1b..efdc7b41 100644 --- a/src/main/java/dev/jcputney/elearning/parser/output/metadata/scorm2004/Scorm2004Metadata.java +++ b/src/main/java/dev/jcputney/elearning/parser/output/metadata/scorm2004/Scorm2004Metadata.java @@ -16,6 +16,8 @@ import dev.jcputney.elearning.parser.input.scorm2004.Scorm2004Manifest; import dev.jcputney.elearning.parser.input.scorm2004.SequencingUsageDetector; import dev.jcputney.elearning.parser.input.scorm2004.SequencingUsageDetector.SequencingLevel; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ADLObjective; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.MapInfo; import dev.jcputney.elearning.parser.input.scorm2004.adl.types.ScormType; import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004Item; import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004Resource; @@ -223,7 +225,7 @@ public static Scorm2004Metadata create(Scorm2004Manifest manifest, boolean xapiE } /** - * Retrieves the list of objectives from a given item in a null-safe manner. + * Retrieves the list of IMS objectives from a given item in a null-safe manner. * * @param item The SCORM item to retrieve objectives from. * @return A list of objectives, or an empty list if null. @@ -232,12 +234,33 @@ private static List getObjectives(Scorm2004Item item) { if (item.getSequencing() != null && item .getSequencing() .getObjectives() != null) { + var objectives = item + .getSequencing() + .getObjectives(); + return Stream + .concat(Stream.ofNullable(objectives.getPrimaryObjective()), + safeStream(objectives.getObjectiveList())) + .toList(); + } + return List.of(); // Return an empty list if objectives are null + } + + /** + * Retrieves the list of ADL objectives from a given item in a null-safe manner. + * + * @param item The SCORM item to retrieve ADL objectives from. + * @return A list of ADL objectives, or an empty list if null. + */ + private static List getAdlObjectives(Scorm2004Item item) { + if (item.getSequencing() != null && item + .getSequencing() + .getAdlObjectives() != null) { return item .getSequencing() - .getObjectives() + .getAdlObjectives() .getObjectiveList(); } - return List.of(); // Return an empty list if objectives are null + return List.of(); } /** @@ -624,9 +647,13 @@ private Set collectGlobalObjectiveIds() { .getOrganizationList() .stream() .flatMap(org -> safeStream(org.getItems())) - .flatMap(item -> safeStream(getObjectives(item))) - .flatMap(obj -> safeStream(obj.getMapInfo())) - .map(Scorm2004ObjectiveMapping::getTargetObjectiveID) + .flatMap(item -> Stream.concat( + safeStream(getObjectives(item)) + .flatMap(obj -> safeStream(obj.getMapInfo())) + .map(Scorm2004ObjectiveMapping::getTargetObjectiveID), + safeStream(getAdlObjectives(item)) + .flatMap(obj -> safeStream(obj.getMapInfoList())) + .map(MapInfo::getTargetObjectiveID))) .filter(id -> id != null && !id.isEmpty()) .collect(Collectors.toCollection(LinkedHashSet::new)); } diff --git a/src/main/java/dev/jcputney/elearning/parser/parsers/Scorm2004Parser.java b/src/main/java/dev/jcputney/elearning/parser/parsers/Scorm2004Parser.java index 6c0774aa..60806262 100644 --- a/src/main/java/dev/jcputney/elearning/parser/parsers/Scorm2004Parser.java +++ b/src/main/java/dev/jcputney/elearning/parser/parsers/Scorm2004Parser.java @@ -18,11 +18,20 @@ import dev.jcputney.elearning.parser.exception.ModuleException; import dev.jcputney.elearning.parser.exception.ModuleParsingException; import dev.jcputney.elearning.parser.input.common.serialization.Scorm2004SchemaValidator; +import dev.jcputney.elearning.parser.input.scorm2004.ADLSeq; +import dev.jcputney.elearning.parser.input.scorm2004.IMSSS; import dev.jcputney.elearning.parser.input.scorm2004.Scorm2004Manifest; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ADLObjective; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ADLObjectives; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.MapInfo; import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004File; import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004Item; import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004Organization; import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004Resource; +import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.objective.Scorm2004Objective; +import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.objective.Scorm2004ObjectiveMapping; +import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.objective.Scorm2004Objectives; +import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.sequencing.Sequencing; import dev.jcputney.elearning.parser.output.metadata.scorm2004.Scorm2004Metadata; import dev.jcputney.elearning.parser.util.XmlParsingUtils; import dev.jcputney.elearning.parser.validation.ValidationIssue; @@ -34,7 +43,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Consumer; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; import javax.xml.stream.XMLStreamException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; import org.xml.sax.SAXException; /** @@ -116,6 +132,7 @@ public Scorm2004Manifest parseManifest(String manifestPath) Scorm2004Manifest manifest = XmlParsingUtils .parseXmlToObject(new ByteArrayInputStream(bytes), getManifestClass(), manifestPath); + restoreSequencingObjectives(manifest, bytes); loadExternalMetadata(manifest); return manifest; } catch (IOException e) { @@ -233,6 +250,351 @@ private void validateSchema(byte[] bytes) throws IOException, ModuleParsingExcep } } + /** + * Restores namespace-distinct IMS and ADL objective containers after Jackson's XML binding has + * collapsed same-local-name {@code objectives} elements. + */ + private void restoreSequencingObjectives(Scorm2004Manifest manifest, byte[] bytes) + throws ManifestParseException { + if (manifest == null || bytes == null || bytes.length == 0) { + return; + } + + try { + Document document = parseNamespaceAwareDocument(bytes); + Element root = document.getDocumentElement(); + restoreOrganizationSequencingObjectives(manifest, root); + restoreSequencingCollectionObjectives(manifest, root); + } catch (IOException | ParserConfigurationException | SAXException e) { + throw new ManifestParseException( + "Failed to restore SCORM 2004 sequencing objectives: " + e.getMessage(), e); + } + } + + private Document parseNamespaceAwareDocument(byte[] bytes) + throws ParserConfigurationException, IOException, SAXException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + setFeatureIfSupported(factory, XMLConstants.FEATURE_SECURE_PROCESSING, true); + setFeatureIfSupported(factory, "http://apache.org/xml/features/disallow-doctype-decl", true); + setFeatureIfSupported(factory, "http://xml.org/sax/features/external-general-entities", + false); + setFeatureIfSupported(factory, "http://xml.org/sax/features/external-parameter-entities", + false); + try { + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + } catch (IllegalArgumentException ignored) { + // Some parsers do not support JAXP access attributes. + } + return factory + .newDocumentBuilder() + .parse(new ByteArrayInputStream(bytes)); + } + + private void setFeatureIfSupported(DocumentBuilderFactory factory, String feature, + boolean enabled) { + try { + factory.setFeature(feature, enabled); + } catch (ParserConfigurationException ignored) { + // Keep parsing with the features this JAXP implementation supports. + } + } + + private void restoreOrganizationSequencingObjectives(Scorm2004Manifest manifest, Element root) { + if (manifest.getOrganizations() == null || manifest + .getOrganizations() + .getOrganizationList() == null) { + return; + } + + Element organizationsElement = firstDirectChild(root, Scorm2004Manifest.NAMESPACE_URI, + "organizations"); + if (organizationsElement == null) { + return; + } + + List organizationElements = directChildren(organizationsElement, + Scorm2004Manifest.NAMESPACE_URI, "organization"); + int organizationIndex = 0; + for (Element organizationElement : organizationElements) { + Scorm2004Organization organization = findOrganization(manifest + .getOrganizations() + .getOrganizationList(), organizationElement, organizationIndex); + organizationIndex++; + if (organization == null) { + continue; + } + + restoreSequencingObjectives(firstDirectChild(organizationElement, IMSSS.NAMESPACE_URI, + "sequencing"), organization.getSequencing()); + restoreItemSequencingObjectives(organization.getItems(), organizationElement); + } + } + + private Scorm2004Organization findOrganization(List organizations, + Element organizationElement, int fallbackIndex) { + String identifier = optionalAttribute(organizationElement, "identifier"); + if (identifier != null) { + for (Scorm2004Organization organization : organizations) { + if (organization != null && identifier.equals(organization.getIdentifier())) { + return organization; + } + } + } + return fallbackIndex < organizations.size() ? organizations.get(fallbackIndex) : null; + } + + private void restoreItemSequencingObjectives(List items, Element parentElement) { + if (items == null || items.isEmpty()) { + return; + } + + List itemElements = directChildren(parentElement, Scorm2004Manifest.NAMESPACE_URI, + "item"); + int itemIndex = 0; + for (Element itemElement : itemElements) { + Scorm2004Item item = findItem(items, itemElement, itemIndex); + itemIndex++; + if (item == null) { + continue; + } + + restoreSequencingObjectives(firstDirectChild(itemElement, IMSSS.NAMESPACE_URI, "sequencing"), + item.getSequencing()); + restoreItemSequencingObjectives(item.getItems(), itemElement); + } + } + + private Scorm2004Item findItem(List items, Element itemElement, + int fallbackIndex) { + String identifier = optionalAttribute(itemElement, "identifier"); + if (identifier != null) { + for (Scorm2004Item item : items) { + if (item != null && identifier.equals(item.getIdentifier())) { + return item; + } + } + } + return fallbackIndex < items.size() ? items.get(fallbackIndex) : null; + } + + private void restoreSequencingCollectionObjectives(Scorm2004Manifest manifest, Element root) { + if (manifest.getSequencingCollection() == null || manifest + .getSequencingCollection() + .getSequencingList() == null) { + return; + } + + Element collectionElement = firstDirectChild(root, IMSSS.NAMESPACE_URI, + "sequencingCollection"); + if (collectionElement == null) { + return; + } + + List sequencingElements = directChildren(collectionElement, IMSSS.NAMESPACE_URI, + "sequencing"); + List sequencingList = manifest + .getSequencingCollection() + .getSequencingList(); + int sequencingIndex = 0; + for (Element sequencingElement : sequencingElements) { + Sequencing sequencing = findSequencing(sequencingList, sequencingElement, sequencingIndex); + sequencingIndex++; + restoreSequencingObjectives(sequencingElement, sequencing); + } + } + + private Sequencing findSequencing(List sequencingList, Element sequencingElement, + int fallbackIndex) { + String id = optionalAttribute(sequencingElement, "ID"); + if (id != null) { + for (Sequencing sequencing : sequencingList) { + if (sequencing != null && id.equals(sequencing.getId())) { + return sequencing; + } + } + } + return fallbackIndex < sequencingList.size() ? sequencingList.get(fallbackIndex) : null; + } + + private void restoreSequencingObjectives(Element sequencingElement, Sequencing sequencing) { + if (sequencingElement == null || sequencing == null) { + return; + } + + Element imsObjectivesElement = firstDirectChild(sequencingElement, IMSSS.NAMESPACE_URI, + "objectives"); + Element adlObjectivesElement = firstDirectChild(sequencingElement, ADLSeq.NAMESPACE_URI, + "objectives"); + if (imsObjectivesElement != null || adlObjectivesElement != null) { + sequencing.setObjectives(parseImsObjectives(imsObjectivesElement)); + sequencing.setAdlObjectives(parseAdlObjectives(adlObjectivesElement)); + } + } + + private Scorm2004Objectives parseImsObjectives(Element objectivesElement) { + if (objectivesElement == null) { + return null; + } + + Scorm2004Objectives objectives = new Scorm2004Objectives(); + objectives.setPrimaryObjective(parseImsObjective(firstDirectChild(objectivesElement, + IMSSS.NAMESPACE_URI, "primaryObjective"))); + + List objectiveList = directChildren(objectivesElement, + IMSSS.NAMESPACE_URI, "objective") + .stream() + .map(this::parseImsObjective) + .toList(); + if (!objectiveList.isEmpty()) { + objectives.setObjectiveList(objectiveList); + } + return objectives; + } + + private Scorm2004Objective parseImsObjective(Element objectiveElement) { + if (objectiveElement == null) { + return null; + } + + Scorm2004Objective objective = new Scorm2004Objective(); + objective.setObjectiveID(optionalAttribute(objectiveElement, "objectiveID")); + setBooleanAttribute(objectiveElement, "satisfiedByMeasure", + objective::setSatisfiedByMeasure); + + Element minNormalizedMeasure = firstDirectChild(objectiveElement, IMSSS.NAMESPACE_URI, + "minNormalizedMeasure"); + if (minNormalizedMeasure != null) { + String value = minNormalizedMeasure + .getTextContent() + .trim(); + if (!value.isEmpty()) { + objective.setMinNormalizedMeasure(Double.valueOf(value)); + } + } + + List mapInfo = directChildren(objectiveElement, + IMSSS.NAMESPACE_URI, "mapInfo") + .stream() + .map(this::parseImsMapInfo) + .toList(); + if (!mapInfo.isEmpty()) { + objective.setMapInfo(mapInfo); + } + return objective; + } + + private Scorm2004ObjectiveMapping parseImsMapInfo(Element mapInfoElement) { + Scorm2004ObjectiveMapping mapping = new Scorm2004ObjectiveMapping(); + mapping.setTargetObjectiveID(optionalAttribute(mapInfoElement, "targetObjectiveID")); + setBooleanAttribute(mapInfoElement, "readSatisfiedStatus", mapping::setReadSatisfiedStatus); + setBooleanAttribute(mapInfoElement, "readNormalizedMeasure", + mapping::setReadNormalizedMeasure); + setBooleanAttribute(mapInfoElement, "writeSatisfiedStatus", + mapping::setWriteSatisfiedStatus); + setBooleanAttribute(mapInfoElement, "writeNormalizedMeasure", + mapping::setWriteNormalizedMeasure); + setBooleanAttribute(mapInfoElement, "readCompletionStatus", + mapping::setReadCompletionStatus); + setBooleanAttribute(mapInfoElement, "writeCompletionStatus", + mapping::setWriteCompletionStatus); + return mapping; + } + + private ADLObjectives parseAdlObjectives(Element objectivesElement) { + if (objectivesElement == null) { + return null; + } + + List objectiveList = directChildren(objectivesElement, ADLSeq.NAMESPACE_URI, + "objective") + .stream() + .map(this::parseAdlObjective) + .toList(); + ADLObjectives objectives = new ADLObjectives(); + if (!objectiveList.isEmpty()) { + objectives.setObjectiveList(objectiveList); + } + return objectives; + } + + private ADLObjective parseAdlObjective(Element objectiveElement) { + ADLObjective objective = new ADLObjective(); + objective.setObjectiveID(optionalAttribute(objectiveElement, "objectiveID")); + List mapInfoList = directChildren(objectiveElement, ADLSeq.NAMESPACE_URI, "mapInfo") + .stream() + .map(this::parseAdlMapInfo) + .toList(); + if (!mapInfoList.isEmpty()) { + objective.setMapInfoList(mapInfoList); + } + return objective; + } + + private MapInfo parseAdlMapInfo(Element mapInfoElement) { + MapInfo mapInfo = new MapInfo(); + mapInfo.setTargetObjectiveID(optionalAttribute(mapInfoElement, "targetObjectiveID")); + setBooleanAttribute(mapInfoElement, "readRawScore", mapInfo::setReadRawScore); + setBooleanAttribute(mapInfoElement, "readMinScore", mapInfo::setReadMinScore); + setBooleanAttribute(mapInfoElement, "readMaxScore", mapInfo::setReadMaxScore); + setBooleanAttribute(mapInfoElement, "readCompletionStatus", + mapInfo::setReadCompletionStatus); + setBooleanAttribute(mapInfoElement, "readProgressMeasure", + mapInfo::setReadProgressMeasure); + setBooleanAttribute(mapInfoElement, "writeRawScore", mapInfo::setWriteRawScore); + setBooleanAttribute(mapInfoElement, "writeMinScore", mapInfo::setWriteMinScore); + setBooleanAttribute(mapInfoElement, "writeMaxScore", mapInfo::setWriteMaxScore); + setBooleanAttribute(mapInfoElement, "writeCompletionStatus", + mapInfo::setWriteCompletionStatus); + setBooleanAttribute(mapInfoElement, "writeProgressMeasure", + mapInfo::setWriteProgressMeasure); + return mapInfo; + } + + private void setBooleanAttribute(Element element, String attributeName, + Consumer setter) { + String value = optionalAttribute(element, attributeName); + if (value != null) { + setter.accept(Boolean.parseBoolean(value)); + } + } + + private String optionalAttribute(Element element, String attributeName) { + if (element == null || !element.hasAttribute(attributeName)) { + return null; + } + String value = element + .getAttribute(attributeName) + .trim(); + return value.isEmpty() ? null : value; + } + + private Element firstDirectChild(Element parent, String namespaceUri, String localName) { + List children = directChildren(parent, namespaceUri, localName); + return children.isEmpty() ? null : children.get(0); + } + + private List directChildren(Element parent, String namespaceUri, String localName) { + if (parent == null) { + return List.of(); + } + + List children = new ArrayList<>(); + Node child = parent.getFirstChild(); + while (child != null) { + if (child instanceof Element element + && namespaceUri.equals(element.getNamespaceURI()) + && localName.equals(element.getLocalName())) { + children.add(element); + } + child = child.getNextSibling(); + } + return children; + } + /** * Loads resources metadata files referenced in the manifest into the metadata object. * diff --git a/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/SequencingUsageDetectorTest.java b/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/SequencingUsageDetectorTest.java index 4fd715b5..e048af1a 100644 --- a/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/SequencingUsageDetectorTest.java +++ b/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/SequencingUsageDetectorTest.java @@ -10,6 +10,8 @@ */ package dev.jcputney.elearning.parser.input.scorm2004; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ADLObjective; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ADLObjectives; import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ConstrainChoiceConsiderations; import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.RollupConsiderations; import dev.jcputney.elearning.parser.input.scorm2004.adl.types.ScormType; @@ -18,8 +20,6 @@ import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004Organizations; import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004Resource; import dev.jcputney.elearning.parser.input.scorm2004.ims.cp.Scorm2004Resources; -import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.objective.Scorm2004Objective; -import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.objective.Scorm2004Objectives; import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.random.RandomizationControls; import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.types.RandomizationTiming; import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.sequencing.ControlMode; @@ -149,10 +149,10 @@ void detectDoesNotTriggerOnMultipleCommonHeuristics() { @Test void detectRecognizesAdlExtensions() { - Scorm2004Objective objective = new Scorm2004Objective(); + ADLObjective objective = new ADLObjective(); objective.setObjectiveID("obj1"); - Scorm2004Objectives adlObjectives = new Scorm2004Objectives(); - adlObjectives.setPrimaryObjective(objective); + ADLObjectives adlObjectives = new ADLObjectives(); + adlObjectives.setObjectiveList(List.of(objective)); Sequencing sequencing = new Sequencing(); sequencing.setAdlObjectives(adlObjectives); diff --git a/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/objective/Scorm2004ObjectiveParserTest.java b/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/objective/Scorm2004ObjectiveParserTest.java index ce7b2670..07842de6 100644 --- a/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/objective/Scorm2004ObjectiveParserTest.java +++ b/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/objective/Scorm2004ObjectiveParserTest.java @@ -17,11 +17,22 @@ import dev.jcputney.elearning.parser.exception.ManifestParseException; import dev.jcputney.elearning.parser.exception.ModuleParsingException; +import dev.jcputney.elearning.parser.impl.access.InMemoryFileAccess; import dev.jcputney.elearning.parser.impl.access.LocalFileAccess; import dev.jcputney.elearning.parser.input.scorm2004.Scorm2004Manifest; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ADLObjective; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.ADLObjectives; +import dev.jcputney.elearning.parser.input.scorm2004.adl.sequencing.MapInfo; +import dev.jcputney.elearning.parser.input.scorm2004.ims.ss.sequencing.Sequencing; +import dev.jcputney.elearning.parser.output.metadata.scorm2004.Scorm2004Metadata; import dev.jcputney.elearning.parser.parsers.Scorm2004Parser; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import javax.xml.stream.XMLStreamException; import org.junit.jupiter.api.Test; @@ -133,4 +144,200 @@ void testParseContentWrapperObjectives() assertTrue(mapping.isWriteSatisfiedStatus()); assertFalse(mapping.isWriteNormalizedMeasure()); } + + @Test + void parseSameIdImsPrimaryAndAdlObjectiveKeepsSeparateContainers() + throws Exception { + String sequencingXml = """ + + + + + + + + + + + """; + Scorm2004Manifest manifest = parseManifestWithSequencing(sequencingXml); + Sequencing sequencing = firstSequencing(manifest); + + Scorm2004Objectives objectives = sequencing.getObjectives(); + assertNotNull(objectives); + Scorm2004Objective primaryObjective = objectives.getPrimaryObjective(); + assertNotNull(primaryObjective); + assertEquals("PRIMARYOBJ", primaryObjective.getObjectiveID()); + + List primaryMaps = primaryObjective.getMapInfo(); + assertNotNull(primaryMaps); + assertEquals(1, primaryMaps.size()); + Scorm2004ObjectiveMapping primaryMap = primaryMaps.get(0); + assertEquals("gObj-A", primaryMap.getTargetObjectiveID()); + assertFalse(primaryMap.isReadSatisfiedStatus()); + assertTrue(primaryMap.isWriteSatisfiedStatus()); + + ADLObjectives adlObjectives = sequencing.getAdlObjectives(); + assertNotNull(adlObjectives); + ADLObjective adlObjective = onlyElement(adlObjectives.getObjectiveList()); + assertEquals("PRIMARYOBJ", adlObjective.getObjectiveID()); + + MapInfo adlMapInfo = onlyElement(adlObjective.getMapInfoList()); + assertEquals("gObj-B", adlMapInfo.getTargetObjectiveID()); + assertFalse(adlMapInfo.isReadRawScore()); + assertTrue(adlMapInfo.isWriteRawScore()); + assertTrue(adlMapInfo.isWriteCompletionStatus()); + assertEquals(Set.of("gObj-A", "gObj-B"), manifest.getGlobalObjectiveIds()); + + Scorm2004Metadata metadata = parseMetadata(sequencingXml); + assertEquals(Set.of("gObj-A", "gObj-B"), metadata.getGlobalObjectiveIds()); + } + + @Test + void parseAdlMapInfoExposesScoreAndProgressFlagsFromXml() + throws Exception { + MapInfo mapInfo = onlyAdlMapInfo(""" + + + + + + """); + + assertEquals("gObj-B", mapInfo.getTargetObjectiveID()); + assertFalse(mapInfo.isReadRawScore()); + assertFalse(mapInfo.isReadMinScore()); + assertFalse(mapInfo.isReadMaxScore()); + assertFalse(mapInfo.isReadCompletionStatus()); + assertFalse(mapInfo.isReadProgressMeasure()); + assertTrue(mapInfo.isWriteRawScore()); + assertTrue(mapInfo.isWriteMinScore()); + assertTrue(mapInfo.isWriteMaxScore()); + assertTrue(mapInfo.isWriteCompletionStatus()); + assertTrue(mapInfo.isWriteProgressMeasure()); + } + + @Test + void parseAdlMapInfoUsesSchemaDefaultsWhenScoreAndProgressFlagsAreOmitted() + throws Exception { + MapInfo mapInfo = onlyAdlMapInfo(""" + + + + + + """); + + assertEquals("gObj-B", mapInfo.getTargetObjectiveID()); + assertTrue(mapInfo.isReadRawScore()); + assertTrue(mapInfo.isReadMinScore()); + assertTrue(mapInfo.isReadMaxScore()); + assertTrue(mapInfo.isReadCompletionStatus()); + assertTrue(mapInfo.isReadProgressMeasure()); + assertFalse(mapInfo.isWriteRawScore()); + assertFalse(mapInfo.isWriteMinScore()); + assertFalse(mapInfo.isWriteMaxScore()); + assertFalse(mapInfo.isWriteCompletionStatus()); + assertFalse(mapInfo.isWriteProgressMeasure()); + } + + private MapInfo onlyAdlMapInfo(String objectivesXml) throws Exception { + Sequencing sequencing = parseSequencing(objectivesXml); + ADLObjectives adlObjectives = sequencing.getAdlObjectives(); + assertNotNull(adlObjectives); + + ADLObjective adlObjective = onlyElement(adlObjectives.getObjectiveList()); + return onlyElement(adlObjective.getMapInfoList()); + } + + private Sequencing parseSequencing(String sequencingChildrenXml) throws Exception { + return firstSequencing(parseManifestWithSequencing(sequencingChildrenXml)); + } + + private Scorm2004Manifest parseManifestWithSequencing(String sequencingChildrenXml) + throws Exception { + Scorm2004Parser parser = new Scorm2004Parser(new InMemoryFileAccess( + zipWithManifest(buildManifest(sequencingChildrenXml)))); + return parser.parseManifest(Scorm2004Parser.MANIFEST_FILE); + } + + private Scorm2004Metadata parseMetadata(String sequencingChildrenXml) throws Exception { + Scorm2004Parser parser = new Scorm2004Parser(new InMemoryFileAccess( + zipWithManifest(buildManifest(sequencingChildrenXml)))); + return parser.parseOnly(); + } + + private Sequencing firstSequencing(Scorm2004Manifest manifest) { + return manifest + .getOrganizations() + .getDefault() + .getItems() + .get(0) + .getSequencing(); + } + + private String buildManifest(String sequencingChildrenXml) { + return """ + + + + ADL SCORM + 2004 4th Edition + + + + SCORM 2004 Objective Regression + + Item 1 + + %s + + + + + + + + + + + """.formatted(sequencingChildrenXml.indent(10)); + } + + private byte[] zipWithManifest(String manifestXml) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + zos.putNextEntry(new ZipEntry(Scorm2004Parser.MANIFEST_FILE)); + zos.write(manifestXml.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + + zos.putNextEntry(new ZipEntry("index.html")); + zos.write("Item 1".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + return baos.toByteArray(); + } + + private T onlyElement(List list) { + assertNotNull(list); + assertEquals(1, list.size()); + return list.get(0); + } } From 5d0a457d9fac563c1c40c5f2e78df7f4cd02541a Mon Sep 17 00:00:00 2001 From: jcputney <42720634+jcputney@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:21:37 -0400 Subject: [PATCH 2/2] Address PR review feedback on objective restoration - Fail closed when XML hardening features cannot be applied, matching ScormVersionDetector - Guard minNormalizedMeasure parsing against NumberFormatException in the DOM restoration pass - Return an empty list from Scorm2004Metadata.getAdlObjectives when the ADL container has a null objective list - Add regression tests for a non-numeric minNormalizedMeasure and an empty adlseq:objectives element --- .../metadata/scorm2004/Scorm2004Metadata.java | 3 ++- .../parser/parsers/Scorm2004Parser.java | 25 +++++++---------- .../Scorm2004ObjectiveParserTest.java | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/main/java/dev/jcputney/elearning/parser/output/metadata/scorm2004/Scorm2004Metadata.java b/src/main/java/dev/jcputney/elearning/parser/output/metadata/scorm2004/Scorm2004Metadata.java index efdc7b41..644b529c 100644 --- a/src/main/java/dev/jcputney/elearning/parser/output/metadata/scorm2004/Scorm2004Metadata.java +++ b/src/main/java/dev/jcputney/elearning/parser/output/metadata/scorm2004/Scorm2004Metadata.java @@ -255,10 +255,11 @@ private static List getAdlObjectives(Scorm2004Item item) { if (item.getSequencing() != null && item .getSequencing() .getAdlObjectives() != null) { - return item + List objectives = item .getSequencing() .getAdlObjectives() .getObjectiveList(); + return objectives != null ? objectives : List.of(); } return List.of(); } diff --git a/src/main/java/dev/jcputney/elearning/parser/parsers/Scorm2004Parser.java b/src/main/java/dev/jcputney/elearning/parser/parsers/Scorm2004Parser.java index 60806262..ddf6227b 100644 --- a/src/main/java/dev/jcputney/elearning/parser/parsers/Scorm2004Parser.java +++ b/src/main/java/dev/jcputney/elearning/parser/parsers/Scorm2004Parser.java @@ -277,12 +277,10 @@ private Document parseNamespaceAwareDocument(byte[] bytes) factory.setNamespaceAware(true); factory.setXIncludeAware(false); factory.setExpandEntityReferences(false); - setFeatureIfSupported(factory, XMLConstants.FEATURE_SECURE_PROCESSING, true); - setFeatureIfSupported(factory, "http://apache.org/xml/features/disallow-doctype-decl", true); - setFeatureIfSupported(factory, "http://xml.org/sax/features/external-general-entities", - false); - setFeatureIfSupported(factory, "http://xml.org/sax/features/external-parameter-entities", - false); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); try { factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); @@ -294,15 +292,6 @@ private Document parseNamespaceAwareDocument(byte[] bytes) .parse(new ByteArrayInputStream(bytes)); } - private void setFeatureIfSupported(DocumentBuilderFactory factory, String feature, - boolean enabled) { - try { - factory.setFeature(feature, enabled); - } catch (ParserConfigurationException ignored) { - // Keep parsing with the features this JAXP implementation supports. - } - } - private void restoreOrganizationSequencingObjectives(Scorm2004Manifest manifest, Element root) { if (manifest.getOrganizations() == null || manifest .getOrganizations() @@ -472,7 +461,11 @@ private Scorm2004Objective parseImsObjective(Element objectiveElement) { .getTextContent() .trim(); if (!value.isEmpty()) { - objective.setMinNormalizedMeasure(Double.valueOf(value)); + try { + objective.setMinNormalizedMeasure(Double.valueOf(value)); + } catch (NumberFormatException ignored) { + // Minimum normalized measure remains null as default. + } } } diff --git a/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/objective/Scorm2004ObjectiveParserTest.java b/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/objective/Scorm2004ObjectiveParserTest.java index 07842de6..d7c50907 100644 --- a/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/objective/Scorm2004ObjectiveParserTest.java +++ b/src/test/java/dev/jcputney/elearning/parser/input/scorm2004/ims/ss/objective/Scorm2004ObjectiveParserTest.java @@ -13,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import dev.jcputney.elearning.parser.exception.ManifestParseException; @@ -253,6 +254,32 @@ void parseAdlMapInfoUsesSchemaDefaultsWhenScoreAndProgressFlagsAreOmitted() assertFalse(mapInfo.isWriteProgressMeasure()); } + @Test + void parseNonNumericMinNormalizedMeasureThrowsManifestParseException() { + assertThrows(ManifestParseException.class, () -> parseManifestWithSequencing(""" + + + not-a-number + + + """)); + } + + @Test + void parseEmptyAdlObjectivesKeepsContainerAndSupportsMetadata() throws Exception { + String objectivesXml = ""; + Sequencing sequencing = parseSequencing(objectivesXml); + + ADLObjectives adlObjectives = sequencing.getAdlObjectives(); + assertNotNull(adlObjectives); + assertTrue(adlObjectives.getObjectiveList() == null + || adlObjectives.getObjectiveList().isEmpty()); + + Scorm2004Metadata metadata = parseMetadata(objectivesXml); + assertNotNull(metadata); + assertTrue(metadata.getGlobalObjectiveIds().isEmpty()); + } + private MapInfo onlyAdlMapInfo(String objectivesXml) throws Exception { Sequencing sequencing = parseSequencing(objectivesXml); ADLObjectives adlObjectives = sequencing.getAdlObjectives();