closedquestion-8.x-3.x-dev/src/Question/CqQuestionAbstract.php
src/Question/CqQuestionAbstract.php
<?php
namespace Drupal\closedquestion\Question;
use Drupal\closedquestion\Question\Textlabel\TextLabel;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Markup;
use Drupal\file\Entity\File;
/**
* Class CqQuestionAbstract.
*
* Abstract base implementation of the CqQuestionInterface interface.
*
* @package Drupal\closedquestion\Question
*/
abstract class CqQuestionAbstract implements CqQuestionInterface {
/**
* The CqUserAnswerInterface to use for storing the student's answer.
*
* @var \Drupal\closedquestion\Question\CqUserAnswerInterface
*/
protected $userAnswer;
/**
* The drupal node object that contains this question.
*
* @var Object
*/
protected $closedQuestion;
/**
* The list of CqListenerQuestionInterface listeners.
*
* @var CqListenerQuestionInterface[]
*/
protected $listeners = array();
/**
* The html to add before the generated html of the question.
*
* @var string
*/
protected $prefix = '';
/**
* The html to add after the generated html of the question.
*
* @var string
*/
protected $postfix = '';
/**
* The path that was used to load this question.
*
* We need to store it because we can't trust the path if the object is
* stored in a cached form and later used from a json call.
*
* @var string
*/
public $usedPath = '';
/**
* Handeled tags.
*
* List of node names or tags in the text that are handled by this question
* when it is used as $context parameter in XmlLib::getTextContent().
* Tags should be in the form of [tagName|tagData].
*
* @var string[]
*
* @see XmlLib::getTextContent()
* @see handleNode()
* @see handleTag()
*/
private $handledTags = array(
'mathresult',
'feedbackblock',
'textlabelresult',
'widget',
);
/**
* Known elements.
*
* A list of XML elements that are known by the Question. If an element is
* found that is not in this list the user probably made a typo and should be
* warned.
*
* @var string[]
*/
protected $knownElements = array(
'#text',
'#comment',
'prefix',
'postfix',
'matheval',
'mathimport',
'textlabel',
'widgetinfo',
);
/**
* The EvalMath object used to evaluate math expressions.
*
* @var \Drupal\closedquestion\Utility\EvalMath
*/
protected $evalMath;
/**
* The list of expressions found in this question.
*
* @var array
* Each item is an array with the fields:
* - expression: string containing the mathematical expression.
* - store: boolean indicating that after this expression is executed the
* state of the variables should be stored.
*/
private $mathExpressions = [];
/**
* The text labels used in this question.
*
* @var \Drupal\closedquestion\Question\Textlabel\TextLabel[]
*/
private $textLabels = [];
/**
* Additional css class/classes to add to the final HTML.
*
* @var string
*/
private $cssClass = '';
/**
* Has the question been fully initialised?
*
* This has to happen during node_view, to give other modules the time to do
* their thing.
*
* @var bool
*/
private $initialised = FALSE;
/**
* List of supported widgets.
*
* @var array
*/
public $supportedwidgets = [];
/**
* List of widget names found embedded in question.
*
* @var array
*/
protected $widgetsfound = [];
/**
* Current user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Messenger service.
*
* @var \Drupal\Core\Messenger\MessengerInterface
*/
protected $messenger;
/**
* XML helper service.
*
* @var \Drupal\closedquestion\Utility\XmlLib
*/
protected $xmlLib;
/**
* Form builder.
*
* @var \Drupal\Core\Form\FormBuilderInterface
*/
protected $formBuilder;
/**
* The renderer service.
*
* @var \Drupal\Core\Render\Renderer
*/
protected $renderer;
/**
* Default constructor.
*/
public function __construct() {
$this->usedPath = isset($_GET['q']) ? $_GET['q'] : '';
$this->currentUser = \Drupal::currentUser();
$this->messenger = \Drupal::messenger();
$this->evalMath = \Drupal::service('closedquestion.utility.eval_math');
$this->xmlLib = \Drupal::service('closedquestion.utility.xml_lib');
$this->formBuilder = \Drupal::formBuilder();
$this->renderer = \Drupal::service('renderer');
}
/**
* Initialises this question from the given DOMElement.
*
* @param \DOMNode $dom
* The XML DOMNode to use to initialise this question.
*/
public function loadXml(\DOMNode $dom) {
// In case loadXml is called by another means than initialise(), we don't
// want initialise() to run.
$this->initialised = TRUE;
foreach ($dom->childNodes as $node) {
switch ($node->nodeName) {
case 'prefix':
$this->prefix = $this->xmlLib->getTextContent($node, $this);
break;
case 'postfix':
$this->postfix = $this->xmlLib->getTextContent($node, $this);
break;
case 'matheval':
$this->handleNodeMathEval($node);
break;
case 'mathimport':
$this->handleNodeMathImport($node);
break;
case 'textlabel':
$this->handleNodeTextlabel($node);
break;
case 'widgetinfo':
$this->handleNodeWidgetinfo($node);
break;
}
}
/* load css class? */
$attribs = $dom->attributes;
$item = $attribs->getNamedItem('cssclass');
if ($item !== NULL) {
$this->cssClass = $item->value;
}
/* execute math */
$this->executeMath();
}
/**
* Executes expression evaluation.
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
*/
private function executeMath() {
if (count($this->mathExpressions) <= 0) {
return;
}
$needs_store = FALSE;
$debugOutput = '';
$vars = $this->userAnswer->getData('emv');
$oldVars = FALSE;
if (!empty($vars)) {
$oldVars = TRUE;
$this->evalMath->setVars($vars);
}
foreach ($this->mathExpressions as $e) {
switch ($e['type']) {
case 'import':
$importUserAnswer = closedquestion_get_useranswer($e['importnode'], $this->userAnswer->getUserId());
$exports = $importUserAnswer->getData('export');
if ($exports === NULL) {
continue;
}
$vars = $this->evalMath->getVars();
$vars[$e['varname']] = $exports[$e['importname']];
$this->evalMath->setVars($vars);
if ($this->currentUser->hasPermission(CLOSEDQUESTION_RIGHT_CREATE)) {
$debugOutput .= t('Expression: %e set to %r', array('%e' => $e['varname'], '%r' => $vars[$e['varname']])) . '<br />';
}
break;
default:
if (!$e['store'] || !$oldVars) {
$result = $this->evalMath->evaluate($e['expression']);
if ($this->currentUser->hasPermission(CLOSEDQUESTION_RIGHT_CREATE)) {
$debugOutput .= t('Expression: %e result: %r', array('%e' => $e['expression'], '%r' => $result)) . '<br />';
}
if ($e['store']) {
$vars = $this->evalMath->getVars();
$this->userAnswer->setData('emv', $vars);
$needs_store = TRUE;
}
}
break;
}
}
if ($needs_store) {
$this->userAnswer->store();
}
if ($this->currentUser->hasPermission(CLOSEDQUESTION_RIGHT_CREATE)) {
$message = closedquestion_make_fieldset('Teacher only debug output', Markup::create('<p>' . $debugOutput . '</p>'), TRUE, TRUE);
$this->messenger->addMessage($message);
}
}
/**
* Handles a node from the question xml, treating it as a MethEval node.
*
* @param \DOMNode $node
* The node to treat as a MathEval node.
*/
public function handleNodeMathEval(\DOMNode $node) {
$expression = array();
$expression['type'] = 'expression';
$expression['expression'] = '';
$expression['store'] = FALSE;
$attribs = $node->attributes;
$item1 = $attribs->getNamedItem('expression');
if ($item1 === NULL) {
$item1 = $attribs->getNamedItem('e');
}
if ($item1 !== NULL) {
$expression['expression'] = $item1->value;
}
$item2 = $attribs->getNamedItem('store');
if ($item2 !== NULL) {
$expression['store'] = (boolean) $item2->value;
}
$this->mathExpressions[] = $expression;
}
/**
* Handles a node from the question xml, treating it as a MathExport node.
*
* @param \DOMNode $node
* The node to treat as a MathExport node.
*/
public function handleNodeMathImport(\DOMNode $node) {
$expression = array();
$expression['type'] = 'import';
$expression['varname'] = '';
$expression['importname'] = '';
$expression['importnode'] = '';
$attribs = $node->attributes;
$item1 = $attribs->getNamedItem('varname');
$item2 = $attribs->getNamedItem('importname');
$item3 = $attribs->getNamedItem('importnode');
if ($item1 === NULL || $item2 === NULL || $item3 === NULL) {
$this->messenger->addMessage(t('Attributes "varname", "importname" and "importnode" required for mathimport tags'));
return;
}
$expression['varname'] = $item1->value;
$expression['importname'] = $item2->value;
$expression['importnode'] = (int) $item3->value;
$this->mathExpressions[] = $expression;
}
/**
* Handles a node from the question xml, treating it as a textlabel node.
*
* @param \DOMNode $node
* The node to treat as a textlabel node.
*/
public function handleNodeTextlabel(\DOMNode $node) {
$textlabel = new TextLabel();
$textlabel->initFromNode($node, $this);
$labelId = $textlabel->getId();
if ($labelId !== NULL && strlen($labelId) > 0) {
if (isset($this->textLabels[$labelId])) {
$this->messenger->addMessage(t('Warning, TextLabels with duplicate ID: %id', array('%id' => $labelId)));
}
else {
$this->textLabels[$labelId] = $textlabel;
}
}
}
/**
* Handles a node from the question xml, treating it as a widgetinfo node.
*
* @param \DOMNode $node
* The node to treat as a widgetinfo node.
*/
public function handleNodeWidgetinfo(\DOMNode $node) {
$type = $node->getAttribute('type');
$widgedId = $node->getAttribute('id');
if ($type !== NULL && $widgedId !== NULL && array_key_exists($type, $this->supportedwidgets)) {
$this->widgetsfound[$widgedId] = $type;
$this->supportedwidgets[$type]->loadXML($node);
}
}
/**
* Do final initialisation of the question.
*
* This has to happen during entity_view, to give other modules the time to do
* their thing.
*/
public function initialise() {
if (!$this->initialised) {
$this->initialised = TRUE;
$firstChild = NULL;
if ($this->closedQuestion->get('field_question_xml')->value) {
$xml = $this->closedQuestion->get('field_question_xml')->value;
$xml = closedquestion_filter_content($this->closedQuestion, $xml);
$dom = new \DomDocument();
$dom->loadXML($xml);
$xpath = new \DOMXPath($dom);
$questions = $xpath->query('/question');
$firstChild = $questions->item(0);
}
if ($firstChild) {
$this->loadXML($firstChild);
}
else {
$this->messenger->addMessage(t('No question found in XML of closed question %id.', array('%id' => $this->closedQuestion->id())));
}
}
}
/**
* Check if the question is already initialised.
*
* @return bool
* TRUE if question is initialised.
*/
public function isInitialised() {
return $this->initialised;
}
/**
* Get the form/html output of this question.
*
* @return string
* Themed html.
*/
abstract public function getOutput();
/**
* Get all the text in the question, for easier reviewing for spelling, etc.
*
* @return array
* Drupal form-api compatible array.
*/
abstract public function getAllText();
/**
* Get the last answer that the user gave to this question.
*
* The type of the returned data depends on the question implementation.
*
* @return mixed
* The last answer.
*/
public function getAnswer() {
return $this->userAnswer->getAnswer();
}
/**
* Sets the user answer.
*
* The type of the data depends on the question implementation.
*
* @param mixed $answer
* The answer.
*/
public function setAnswer($answer) {
$this->userAnswer->setAnswer($answer);
}
/**
* Get the css class.
*
* @return string
* The css class.
*/
public function getCssClass() {
return $this->cssClass;
}
/**
* Get the list of hotspots defined in this question.
*
* To be overridden by question types that have hotspots.
*
* @return \Drupal\closedquestion\Question\Mapping\CqHotspotInterface[]
* Hotspots array.
*/
public function getHotspots() {
return [];
}
/**
* Get the list of draggables defined in this question.
*
* To be overridden by question types that have draggables.
*
* @return \Drupal\closedquestion\Question\Mapping\CqDraggable[]
* Array of draggable objects.
*/
public function getDraggables() {
return [];
}
/**
* Form builder for the question-form of this question.
*
* Since Form API requires a form class the QuestionForm is used,
* which will call this method.
*
* @see \Drupal\closedquestion\Form\QuestionForm
* @see submitAnswer()
* @ingroup forms
*/
public function getForm($formState) {
$form = array();
foreach ($this->widgetsfound as $widgetname) {
$this->supportedwidgets[$widgetname]->getForm($form);
}
return $form;
}
/**
* Form submission handler for getForm()
*
* Since the form submit handler can not be the method of an object instance,
* ClosedQuesion will receive the form_submit, fetch the object from the form
* cache and forward the submit to the object.
*
* @see hook_form_submit()
*/
abstract public function submitAnswer($form, FormStateInterface $form_state);
/**
* Run any checks to see if the last given answer is correct.
*
* This method is called by isCorrect() and the result is cached.
*
* @return bool
* TRUE if the user answered correctly.
*/
abstract public function checkCorrect();
/**
* Implements CqQuestionInterface::isCorrect()
*/
public function isCorrect($force = FALSE) {
if ($force || $this->userAnswer->isCorrect() == -1) {
$wasCorrect = $this->userAnswer->onceCorrect();
$this->userAnswer->setCorrect($this->checkCorrect());
if (!$wasCorrect && $this->userAnswer->isCorrect()) {
$this->fireFirstSolutionFound();
}
}
return ($this->userAnswer->isCorrect() > 0);
}
/**
* Implements CqQuestionInterface::onceCorrect()
*/
public function onceCorrect() {
return $this->userAnswer->onceCorrect();
}
/**
* Implements CqQuestionInterface::getTries()
*/
public function getTries() {
return $this->userAnswer->getTries();
}
/**
* Implements CqQuestionInterface::reset()
*/
public function reset() {
$this->userAnswer->reset();
}
/**
* Add a tag to the list of tags handled by this object.
*
* @param string $tag
* The tag to add.
*
* @see XmlLib::getTextContent()
* @see handled_tags()
*/
public function registerTag($tag) {
$this->handledTags[] = $tag;
}
/**
* Get the list of tags handled by this object.
*
* @return string[]
* The list of tags.
*
* @see XmlLib::getTextContent()
* @see handled_tags()
*/
public function getHandledTags() {
return $this->handledTags;
}
/**
* Get the html required for implementing the given XML node.
*
* XmlLib::getTextContent() will call this method when this object is the
* $context and it finds an XML node that is listed in $this->handled_tags.
*
* @param \DOMNode $node
* The XML node to handle.
* @param bool $delay
* If true, some XML nodes are replaced with a [] tag so they can be
* processed later. This can be used when not all data needed for full
* processing is available yet.
*
* @return string
* The html that implements the needed functionality.
*
* @see XmlLib::getTextContent()
* @see handled_tags()
*/
public function handleNode(\DOMNode $node, $delay = FALSE) {
$retval = '';
switch (mb_strtolower($node->nodeName)) {
case 'mathresult':
$attribs = $node->attributes;
$item = $attribs->getNamedItem('expression');
if ($item === NULL) {
$item = $attribs->getNamedItem('e');
}
if ($item !== NULL) {
$expression = $item->value;
if ($delay) {
$retval .= '[mathresult|' . $expression . ']';
}
else {
$retval .= $this->evalMath->e($expression);
}
}
break;
case 'feedbackblock':
$attribs = $node->attributes;
$item = $attribs->getNamedItem('identifier');
if ($item === NULL) {
$item = $attribs->getNamedItem('id');
}
if ($item === NULL) {
$this->messenger->addMessage(t('FeedbackBlock without id'));
}
else {
$id = $item->value;
$retval .= '<span class="cqFbBlock cqFb-' . $id . '" ></span>';
}
break;
case 'textlabelresult':
$attribs = $node->attributes;
$item = $attribs->getNamedItem('identifier');
if ($item === NULL) {
$item = $attribs->getNamedItem('id');
}
if ($item === NULL) {
$this->messenger->addMessage(t('TextLabelResult requested without id.'));
}
else {
$labelId = $item->value;
$item = $attribs->getNamedItem('mathvariable');
if ($item === NULL) {
$item = $attribs->getNamedItem('variable');
}
if ($item === NULL) {
$this->messenger->addMessage(t('TextLabelResult requested without variable name.'));
}
else {
$variable = $item->value;
if ($delay) {
$retval .= '[textlabelresult|' . $labelId . '|' . $variable . ']';
}
else {
$retval .= $this->handleTag('textlabelresult', $labelId . '|' . $variable);
}
}
}
break;
case 'widget':
$attribs = $node->attributes;
$item = $attribs->getNamedItem('id');
if ($item !== NULL) {
$widgetid = $item->value;
if (array_key_exists($widgetid, $this->widgetsfound)) {
$type = $this->widgetsfound[$widgetid];
if (array_key_exists($type, $this->supportedwidgets)) {
$retval .= '<div type="' . $type . '" id="' . $widgetid . '" ></div>';
}
}
else {
$this->messenger->addMessage(t('No matching widgetinfo found for widget with id: %id', ['%id' => $widgetid]), 'warning');
}
}
break;
}
return $retval;
}
/**
* Get the html required for implementing the given tag.
*
* Function cq_replace_tags() will call this method when this object
* is the $context and it finds a tag that is listed in $this->handled_tags.
*
* Tags have to be in the form [tagName|tagData]
*
* @param string $tagName
* The name of the tag that was found.
* @param string $tagData
* The data of the tag that was found.
*
* @return string
* HTML string.
*/
public function handleTag($tagName, $tagData) {
$retval = '';
switch ($tagName) {
case 'mathresult':
$retval .= $this->evalMath->e($tagData);
break;
case 'textlabelresult':
$data = explode('|', $tagData);
$id = $data[0];
$variable = $data[1];
if (isset($this->textLabels[$id])) {
$value = $this->evaluateMath($variable);
$retval .= $this->textLabels[$id]->getValue($value);
}
else {
$retval .= t('Unknown TextLabel: %s.', array('%s' => $data[0]));
}
break;
case 'feedbackblock':
$retval .= '<span class="cqFbBlock cqFb-' . $tagData . '" ></span>';
break;
}
return $retval;
}
/**
* Evaluate the given expression in the current evalMath context.
*
* @param string $expression
* The expression to evaluate.
*
* @return string
* The result of the evaluation of the expression.
*/
public function evaluateMath($expression) {
if (!empty($expression)) {
return $this->evalMath->e($expression);
}
}
/**
* Check if this question has any math associated with it so far.
*
* @return bool
* TRUE if there is any math in the question so far, FALSE otherwise.
*/
public function hasMath() {
return ($this->evalMath !== NULL);
}
/**
* Implements CqQuestionInterface::addListener()
*/
public function addListener(CqListenerQuestionInterface $listener) {
$this->listeners[] = & $listener;
}
/**
* Return an array with all the feedback items that should be active.
*
* @return \Drupal\closedquestion\Question\Mapping\CqFeedback[]
* Array of feedback items.
*/
abstract public function getFeedbackItems();
/**
* Inserts the form item that shows the feedback, into the given form.
*
* @param array $form
* The form into which the feedback should be inserted.
* @param string $wrapper_id
* Array key and HTML id to give the feedback wrapper item. The same id
* must be passed to insertSubmit() for the standard feedback-replacement
* AHAH functionality.
*
* @see insertSubmit()
*/
public function insertFeedback(array &$form, $wrapper_id) {
// This wrapper will be used for AHAH replacement upon form submit.
$form[$wrapper_id] = array(
'#theme_wrappers' => array('container'),
'#attributes' => array(
'id' => $wrapper_id,
'class' => array('cq-feedback-wrapper'),
),
);
$feedbackItems = $this->getFeedbackItems();
if (count($feedbackItems) > 0) {
$attribs = array();
if (!$this->isCorrect()) {
$attribs['class'] = array('cq_error');
}
else {
$attribs['class'] = array('cq_correct');
}
$form[$wrapper_id]['feedback'] = array(
'#type' => 'fieldset',
'#title' => t('Feedback'),
'#attributes' => $attribs,
);
foreach ($feedbackItems as $nr => $fb) {
$form[$wrapper_id]['feedback']['cq-feedback-' . $nr] = array(
'#type' => 'item',
'#markup' => Markup::create($fb->getText()),
'#weight' => $nr,
);
if ($fb->getImage() != '') {
$form[$wrapper_id]['feedback']['cq-feedback-image' . $nr] = array(
'#type' => 'item',
'#markup' => '<img class="cqImageFeedback" src="' . $fb->getImage() . '" />',
'#weight' => $nr,
);
}
}
}
}
/**
* Inserts the standard form submit element.
*
* It uses Drupal AHAH to replace the feedback item built by insertFeedback().
*
* @param array $form
* The form into which the submit should be inserted.
* @param string $feedback_wrapper_id
* The HTML id of the feedback's wrapper element.
*
* @see insertFeedback()
*/
public function insertSubmit(array &$form, $feedback_wrapper_id) {
$form['submit'] = array(
'#type' => 'submit',
'#value' => t('Submit Answer'),
'#ajax' => array(
'callback' => 'closedquestion_submit_answer_js',
'wrapper' => $feedback_wrapper_id,
'method' => 'replace',
'progress' => array('type' => 'throbber', 'message' => t('Please wait...')),
'event' => 'mousedown',
'prevent' => 'click',
),
);
}
/**
* Returns the node of this question.
*
* @return object
* Drupal node object.
*/
public function getClosedQuestion() {
return $this->closedQuestion;
}
/**
* Returns a reference to the UserAnswer of this question.
*
* @return object
* cqUserAnswer object.
*/
public function getUserAnswer() {
return $this->userAnswer;
}
/**
* Fires the 'first solution found' event.
*
* Inform any listeners that the student found the correct solution for the
* first time.
*/
public function fireFirstSolutionFound() {
foreach ($this->listeners as $listener) {
$listener->firstSolutionFound($this->userAnswer->getTries());
}
}
/**
* Fires the 'get extra feedback items' event.
*
* Ask any listeners if they want to add additional feedback items to the
* question.
*
* @param \Drupal\closedquestion\Question\CqQuestionInterface $caller
* The question that requests extra feedback.
* @param int $tries
* The number of incorrect tries the student did.
*
* @return \Drupal\closedquestion\Question\Mapping\CqFeedback[]
* Array of additional feedback items.
*/
public function fireGetExtraFeedbackItems(CqQuestionInterface $caller, $tries) {
$feedback = array();
foreach ($this->listeners as $listener) {
$feedback = array_merge($feedback, $listener->getExtraFeedbackItems($caller, $tries));
}
return $feedback;
}
/**
* Registers new widget.
*
* @param object $widget
* The widget object to register.
*/
public function registerWidget($widget) {
$this->supportedwidgets[$widget::WIDGETTYPE] = new $widget();
}
/**
* Returns the URL leading to a Media file by parsing a Media tag.
*
* @param string $tagString
* A Media Tag [[{"fid": <int> ... }]].
*
* @return string
* The URL, or $tagString in case no tag could be parsed.
*/
public function getUrlFromMediaTag($tagString) {
$matchImgUrlAsArray = json_decode($tagString, TRUE);
if ($matchImgUrlAsArray !== NULL) {
$file = File::load($matchImgUrlAsArray[0][0]['fid']);
return file_create_url($file->getFileUri());
}
else {
return $tagString;
}
}
}
