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..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 @@ -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,14 +234,36 @@ private static List getObjectives(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())) + .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) { + List objectives = item + .getSequencing() + .getAdlObjectives() + .getObjectiveList(); + return objectives != null ? objectives : List.of(); + } + return List.of(); + } + /** * Wraps a potentially null collection in a stream. * @@ -624,9 +648,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..ddf6227b 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,344 @@ 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); + 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, ""); + } catch (IllegalArgumentException ignored) { + // Some parsers do not support JAXP access attributes. + } + return factory + .newDocumentBuilder() + .parse(new ByteArrayInputStream(bytes)); + } + + 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()) { + try { + objective.setMinNormalizedMeasure(Double.valueOf(value)); + } catch (NumberFormatException ignored) { + // Minimum normalized measure remains null as default. + } + } + } + + 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..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,15 +13,27 @@ 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; 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 +145,226 @@ 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()); + } + + @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(); + 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); + } }