HEX
Server: Apache
System: Linux WWW 6.1.0-40-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.153-1 (2025-09-20) x86_64
User: web11 (1011)
PHP: 8.2.29
Disabled: NONE
Upload Files
File: /var/www/apklausos/application/models/QuestionTheme.php
<?php

use LimeSurvey\Helpers\questionHelper;

/**
 * This is the model class for table "{{question_themes}}".
 *
 * The following are the available columns in table '{{question_themes}}':
 *
 * @property integer $id
 * @property string  $name
 * @property string  $visible
 * @property string  $xml_path
 * @property string  $image_path
 * @property string  $title
 * @property string  $creation_date
 * @property string  $author
 * @property string  $author_email
 * @property string  $author_url
 * @property string  $copyright
 * @property string  $license
 * @property string  $version
 * @property string  $api_version
 * @property string  $description
 * @property string  $last_update
 * @property integer $owner_id
 * @property string  $theme_type
 * @property string  $question_type
 * @property integer $core_theme
 * @property string  $extends
 * @property string  $group
 * @property string  $settings
 */
class QuestionTheme extends LSActiveRecord
{
    const THEME_TYPE_CORE = 'coreQuestion';
    const THEME_TYPE_CUSTOM = 'customCoreTheme';
    const THEME_TYPE_USER = 'customUserTheme';

    /**
     * @return string the associated database table name
     */
    public function tableName()
    {
        return '{{question_themes}}';
    }

    /**
     * @return array validation rules for model attributes.
     */
    public function rules()
    {
        // NOTE: you should only define rules for those attributes that
        // will receive user inputs.
        return array(
            [
                'name',
                'unique',
                'caseSensitive' => false,
                'criteria'      => [
                    'condition' => 'extends=:extends',
                    'params'    => [
                        ':extends' => $this->extends
                    ]
                ],
            ],
            array('name, title, api_version, question_type', 'required'),
            array('owner_id', 'numerical', 'integerOnly' => true),
            array('core_theme', 'boolean'),
            array('name, author, theme_type, question_type, extends, group', 'length', 'max' => 150),
            array('visible', 'length', 'max' => 1),
            array('xml_path, image_path, author_email, author_url', 'length', 'max' => 255),
            array('title', 'length', 'max' => 100),
            array('version, api_version', 'length', 'max' => 45),
            array('creation_date, copyright, license, description, last_update, settings', 'safe'),
            // The following rule is used by search().
            array('id, name, visible, xml_path, image_path, title, creation_date, author, author_email, author_url, copyright, license, version, api_version, description, last_update, owner_id, theme_type, question_type, core_theme, extends, group, settings', 'safe', 'on' => 'search'),
        );
    }

    /**
     * @return array relational rules.
     */
    public function relations()
    {
        return array();
    }

    /** @inheritdoc */
    public function scopes()
    {
        return array(
            // 'base' themes are the ones that don't extend any question type/theme.
            'base' => array(
                'condition' => 'core_theme = :true AND extends = :extends',
                'params' => array(':true' => true, ':extends' => '')
            ),
        );
    }

    /**
     * @return array customized attribute labels (name=>label)
     */
    public function attributeLabels()
    {
        return array(
            'id'            => 'ID',
            'name'          => 'Name',
            'visible'       => 'Visible',
            'xml_path'      => 'Xml Path',
            'image_path'    => 'Image Path',
            'title'         => 'Title',
            'creation_date' => 'Creation Date',
            'author'        => 'Author',
            'author_email'  => 'Author Email',
            'author_url'    => 'Author Url',
            'copyright'     => 'Copyright',
            'license'       => 'License',
            'version'       => 'Version',
            'api_version'   => 'Api Version',
            'description'   => 'Description',
            'last_update'   => 'Last Update',
            'owner_id'      => 'Owner',
            'theme_type'    => 'Theme Type',
            'question_type' => 'Question Type',
            'core_theme'    => 'Core Theme',
            'extends'       => 'Extends',
            'group'         => 'Group',
            'settings'      => 'Settings',
        );
    }

    /**
     * Retrieves a list of models based on the current search/filter conditions.
     *
     * Typical usecase:
     * - Initialize the model fields with values from filter form.
     * - Execute this method to get CActiveDataProvider instance which will filter
     * models according to data in model fields.
     * - Pass data provider to CGridView, CListView or any similar widget.
     *
     * @return CActiveDataProvider the data provider that can return the models
     * based on the search/filter conditions.
     */
    public function search()
    {
        $pageSizeTemplateView = App()->user->getState('pageSizeTemplateView', App()->params['defaultPageSize']);

        $criteria = new LSDbCriteria();
        $criteria->compare('id', $this->id);
        $criteria->compare('name', $this->name, true);
        $criteria->compare('visible', $this->visible, true);
        $criteria->compare('xml_path', $this->xml_path, true);
        $criteria->compare('image_path', $this->image_path, true);
        $criteria->compare('title', $this->title, true);
        $criteria->compare('creation_date', $this->creation_date, true);
        $criteria->compare('author', $this->author, true);
        $criteria->compare('author_email', $this->author_email, true);
        $criteria->compare('author_url', $this->author_url, true);
        $criteria->compare('copyright', $this->copyright, true);
        $criteria->compare('license', $this->license, true);
        $criteria->compare('version', $this->version, true);
        $criteria->compare('api_version', $this->api_version, true);
        $criteria->compare('description', $this->description, true);
        $criteria->compare('last_update', $this->last_update, true);
        $criteria->compare('owner_id', $this->owner_id);
        $criteria->compare('theme_type', $this->theme_type, true);
        $criteria->compare('question_type', $this->question_type, true);
        $criteria->compare('core_theme', $this->core_theme);
        $criteria->compare('extends', $this->extends, true);
        $criteria->compare('group', $this->group, true);
        $criteria->compare('settings', $this->settings, true);
        $sort = new CSort();
        $sort->defaultOrder = 'name';
        return new CActiveDataProvider($this, array(
            'criteria'   => $criteria,
            'sort'      => $sort,
            'pagination' => array(
                'pageSize' => $pageSizeTemplateView,
            ),
        ));
    }

    /**
     * Returns the static model of the specified AR class.
     * Please note that you should have this exact method in all your CActiveRecord descendants!
     *
     * @param string $className active record class name.
     *
     * @return static
     */
    public static function model($className = __CLASS__)
    {
        return parent::model($className);
    }

    /**
     * Returns this table's primary key
     *
     * @access public
     * @return string
     */
    public function primaryKey()
    {
        return 'id';
    }

    /**
     * Returns visibility button.
     *
     * @return string|array
     */
    public function getVisibilityButton()
    {
        // don't show any buttons if user doesn't have update permission
        if (!Permission::model()->hasGlobalPermission('templates', 'update')) {
            return '';
        }
        $bVisible = $this->visible == 'Y' ? true : false;
        $aButtons = [
            'visibility_button' => [
                'url'     => $sToggleVisibilityUrl = App()->getController()->createUrl('admin/questionthemes/sa/togglevisibility', ['id' => $this->id]),
                'visible' => $bVisible
            ]
        ];
        $sButtons = App()->getController()->renderPartial('./theme_buttons', ['id' => $this->id, 'buttons' => $aButtons], true);
        return $sButtons;
    }

    /**
     * Install Button for the available questions
     */
    public function getManifestButtons()
    {
        $sLoadLink = CHtml::form(array("themeOptions/importManifest/"), 'post', array('id' => 'forminstallquestiontheme', 'name' => 'forminstallquestiontheme')) .
            "<input type='hidden' name='templatefolder' value='" . $this->getRelativeXmlPath() . "'>
            <input type='hidden' name='theme_type' value='" . $this->getThemeType() . "'>
            <input type='hidden' name='theme' value='questiontheme'>
            <button id='template_options_link_" . $this->name . "'class='btn btn-outline-secondary btn-block'>
            <span class='ri-download-fill'></span>
            " . gT('Install') . "
            </button>
            </form>";

        return $sLoadLink;
    }

    /**
     * Import config manifest to database.
     *
     * @param string $sXMLDirectoryPath the relative path to the Question Theme XML directory
     * @param bool   $bSkipConversion   If converting should be skipped
     * @param $bThrowConversionException If true, throws exception instead of redirecting
     * @return bool|string
     * @throws Exception
     * @todo Please never redirect at this level, only from controllers.
     */
    public function importManifest($sXMLDirectoryPath, $bSkipConversion = false, $bThrowConversionException = false)
    {
        if (empty($sXMLDirectoryPath)) {
            throw new InvalidArgumentException('$templateFolder cannot be empty');
        }

        // convert Question Theme
        if ($bSkipConversion === false) {
            $aConvertSuccess = self::convertLS3toLS5($sXMLDirectoryPath);
            if (!$aConvertSuccess['success']) {
                if ($bThrowConversionException) {
                    throw new Exception($aConvertSuccess['message']);
                } else {
                    App()->setFlashMessage($aConvertSuccess['message'], 'error');
                    App()->getController()->redirect(array("themeOptions/index#questionthemes"));
                }
            }
        }

        /** @var array */
        $aQuestionMetaData = self::getQuestionMetaData($sXMLDirectoryPath);

        if (empty($aQuestionMetaData)) {
            throw new Exception('Found no question theme metadata');
        }

        /** @var array<string, mixed> */
        // todo proper error handling should be done before in getQuestionMetaData via validate()
        $aMetaDataArray = self::getMetaDataArray($aQuestionMetaData);

        $this->setAttributes($aMetaDataArray, false);
        if ($this->save()) {
            return $aQuestionMetaData['title'];
        } else {
            throw new Exception('Could not save question theme metadata: ' . json_encode($this->errors));
        }
    }

    /**
     * Returns question themes available in the filesystem AND installed in the database
     *
     * @return array
     * @throws Exception
     */
    public function getAvailableQuestionThemes()
    {
        $aAvailableThemes = [];
        $aThemes = $this->getAllQuestionMetaData();
        $questionsInDB = $this->findAll();

        if (!empty($aThemes['available_themes'])) {
            if (!empty($questionsInDB)) {
                foreach ($questionsInDB as $questionInDB) {
                    if (array_key_exists($questionKey = $questionInDB->name . '_' . $questionInDB->question_type, $aThemes['available_themes'])) {
                        unset($aThemes['available_themes'][$questionKey]);
                    }
                }
            }
            foreach ($aThemes['available_themes'] as $questionMetaData) {
                // TODO: replace by manifest
                $questionTheme = new QuestionTheme();
                $metaDataArray = self::getMetaDataArray($questionMetaData);
                $questionTheme->setAttributes($metaDataArray, false);
                $aAvailableThemes[] = $questionTheme;
            }
        }

        return [
            'available_themes' => $aAvailableThemes,
            'broken_themes' => $aThemes['broken_themes']
        ];
    }

    /**
     * Returns an array of all question themes and their metadata, split into available_themes and broken_themes
     *
     * @param bool $core
     * @param bool $custom
     * @param bool $user
     * @return array
     * @todo Move to service class
     */
    public function getAllQuestionMetaData($core = true, $custom = true, $user = true)
    {
        $questionsMetaData = $aBrokenQuestionThemes = [];
        $questionDirectoriesAndPaths = $this->getAllQuestionXMLPaths($core, $custom, $user);
        if (isset($questionDirectoriesAndPaths) && !empty($questionDirectoriesAndPaths)) {
            foreach ($questionDirectoriesAndPaths as $directory => $questionConfigFilePaths) {
                foreach ($questionConfigFilePaths as $questionConfigFilePath) {
                    try {
                        $questionMetaData = self::getQuestionMetaData($questionConfigFilePath);
                        $questionsMetaData[$questionMetaData['name'] . '_' . $questionMetaData['questionType']] = $questionMetaData;
                    } catch (Exception $e) {
                        array_push($aBrokenQuestionThemes, [
                            'path'    => $questionConfigFilePath,
                            'exception' => $e
                        ]);
                    }
                }
            }
        }
        return $aQuestionThemes = [
            'available_themes' => $questionsMetaData,
            'broken_themes'    => $aBrokenQuestionThemes
        ];
    }

    /**
     * Read all the MetaData for given Question XML definition
     *
     * @param string $pathToXmlFolder
     * @return array Question Meta Data
     * @throws Exception
     * @todo Replace assoc array with DTO
     */
    public static function getQuestionMetaData($pathToXmlFolder)
    {
        $questionDirectories = self::getQuestionThemeDirectories();
        foreach ($questionDirectories as $key => $questionDirectory) {
            $questionDirectories[$key] = str_replace('\\', '/', (string) $questionDirectory);
        }

        $pathToXmlFolder = str_replace('\\', '/', $pathToXmlFolder);
        if (\PHP_VERSION_ID < 80000) {
            $bOldEntityLoaderState = libxml_disable_entity_loader(true);
        }
        $sQuestionConfigFilePath = $pathToXmlFolder . DIRECTORY_SEPARATOR . 'config.xml';
        if (!file_exists($sQuestionConfigFilePath)) {
            throw new Exception(sprintf(gT('Extension configuration file is missing at %s.'), $sQuestionConfigFilePath));
        }
        $sQuestionConfigFile = file_get_contents($sQuestionConfigFilePath);  // @see: Now that entity loader is disabled, we can't use simplexml_load_file; so we must read the file with file_get_contents and convert it as a string
        $oQuestionConfig = simplexml_load_string($sQuestionConfigFile);

        if (\PHP_VERSION_ID < 80000) {
            libxml_disable_entity_loader($bOldEntityLoaderState);
        }

        // read all metadata from the provided $pathToXmlFolder
        $questionMetaData = json_decode(json_encode($oQuestionConfig->metadata), true);
        if (!isset($questionMetaData['questionType'])) {
            throw new Exception('Missing attribute questionType in meta data');
        }

        $aQuestionThemes = QuestionTheme::model()->findAll(
            '(question_type = :question_type AND extends = :extends)',
            [
                ':question_type' => $questionMetaData['questionType'],
                ':extends'       => '',
            ]
        );
        //set extends if there is allready an existing Question with this type
        if (empty($aQuestionThemes)) {
            $questionMetaData['extends'] = '';
        } else {
            $questionMetaData['extends'] = $questionMetaData['questionType'];
        }

        // get custom previewimage if defined
        if (!empty($oQuestionConfig->files->preview->filename)) {
            $previewFileName = json_decode(json_encode($oQuestionConfig->files->preview->filename), true)[0];
            $questionMetaData['image_path'] = DIRECTORY_SEPARATOR . $pathToXmlFolder . '/assets/' . $previewFileName;
        }

        $questionMetaData['xml_path'] = $pathToXmlFolder;

        // set settings as json
        $questionMetaData['settings'] = json_encode([
            'subquestions'     => $questionMetaData['subquestions'] ?? 0,
            'other'            => $questionMetaData['other'] ?? false,
            'answerscales'     => $questionMetaData['answerscales'] ?? 0,
            'hasdefaultvalues' => $questionMetaData['hasdefaultvalues'] ?? 0,
            'assessable'       => $questionMetaData['assessable'] ?? 0,
            'class'            => $questionMetaData['class'] ?? '',
        ]);

        // override MetaData depending on directory
        if (substr($pathToXmlFolder, 0, strlen((string) $questionDirectories[self::THEME_TYPE_CORE])) === $questionDirectories[self::THEME_TYPE_CORE]) {
            $questionMetaData['coreTheme'] = 1;
            $questionMetaData['image_path'] = App()->getConfig("imageurl") . '/screenshots/' . self::getQuestionThemeImageName($questionMetaData['questionType']);
        }
        if (substr($pathToXmlFolder, 0, strlen((string) $questionDirectories[self::THEME_TYPE_CUSTOM])) === $questionDirectories[self::THEME_TYPE_CUSTOM]) {
            $questionMetaData['coreTheme'] = 1;
        }
        if (substr($pathToXmlFolder, 0, strlen((string) $questionDirectories[self::THEME_TYPE_USER])) === $questionDirectories[self::THEME_TYPE_USER]) {
            $questionMetaData['coreTheme'] = 0;
        }

        // get Default Image if undefined
        if (empty($questionMetaData['image_path']) || !file_exists(App()->getConfig('rootdir') . $questionMetaData['image_path'])) {
            $questionMetaData['image_path'] = App()->getConfig("imageurl") . '/screenshots/' . self::getQuestionThemeImageName($questionMetaData['questionType']);
        }

        return $questionMetaData;
    }

    /**
     * Find all XML paths for specified Question Root folders
     *
     * @param bool $core
     * @param bool $custom
     * @param bool $user
     *
     * @return array
     */
    public static function getAllQuestionXMLPaths($core = true, $custom = true, $user = true)
    {
        $questionDirectories = self::getQuestionThemeDirectories();
        $questionDirectoriesAndPaths = [];
        if ($core) {
            $coreQuestionsPath = $questionDirectories[self::THEME_TYPE_CORE];
            $selectedQuestionDirectories[] = $coreQuestionsPath;
        }
        if ($custom) {
            $customQuestionThemesPath = $questionDirectories[self::THEME_TYPE_CUSTOM];
            $selectedQuestionDirectories[] = $customQuestionThemesPath;
        }
        if ($user) {
            $userQuestionThemesPath = $questionDirectories[self::THEME_TYPE_USER];
            if (!is_dir($userQuestionThemesPath)) {
                mkdir($userQuestionThemesPath, 0777, true);
            }
            $selectedQuestionDirectories[] = $userQuestionThemesPath;
        }

        if (isset($selectedQuestionDirectories)) {
            foreach ($selectedQuestionDirectories as $questionThemeDirectory) {
                $directory = new RecursiveDirectoryIterator($questionThemeDirectory);
                $iterator = new RecursiveIteratorIterator($directory);
                foreach ($iterator as $info) {
                    $ext = pathinfo((string) $info->getPathname(), PATHINFO_EXTENSION);
                    if ($ext == 'xml') {
                        $questionDirectoriesAndPaths[$questionThemeDirectory][] = dirname((string) $info->getPathname());
                    }
                }
            }
        }
        return $questionDirectoriesAndPaths;
    }


    /**
     * @param QuestionTheme $oQuestionTheme
     *
     * @return array|false
     * @todo move actions to its controller and split between controller and model, related search for: 1573123789741
     * @todo Move to QuestionThemeInstaller
     */
    public static function uninstall($oQuestionTheme)
    {
        if (!Permission::model()->hasGlobalPermission('templates', 'delete')) {
            return false;
        }

        // Don't allow deletion of core question themes (themes delivered with Lime)
        if ($oQuestionTheme->core_theme == 1) {
            return [
                'error'  => gT('Core question themes cannot be uninstalled.'),
                'result' => false
            ];
        }

        // TODO: Now that core question themes can't be deleted, the following check
        //       doesn't seem necessary because, at least for now, user question themes
        //       always extend a question type.

        // if this questiontype is extended, it cannot be deleted
        if (empty($oQuestionTheme->extends)) {
            $aQuestionThemes = self::model()->findAll(
                'extends = :extends AND NOT id = :id',
                [
                    ':extends' => $oQuestionTheme->question_type,
                    ':id'      => $oQuestionTheme->id
                ]
            );
            if (!empty($aQuestionThemes)) {
                return [
                    'error'  => gT('Question type is being extended and cannot be uninstalled'),
                    'result' => false
                ];
            };
        }

        // todo optimize function for very big surveys, eventually in yii 2 or 3 with batch processing / if this is breaking in Yii 1 use CDbDataReader $query = new CDbDataReader($command), $query->read()
        $aQuestions = Question::model()->count(
            'type = :type AND question_theme_name = :theme AND parent_qid = :parent_qid',
            [
                ':type'       => $oQuestionTheme->question_type,
                ':theme'      => $oQuestionTheme->name,
                ':parent_qid' => 0
            ]
        );
        if (!empty($aQuestions)) {
            // There are questions using this theme. Don't delete it
            $bDeleteTheme = false;
        }

        // Just in case, if this is a core (base) theme we also check if there are any questions without theme name (this shouldn't happen)
        if (empty($oQuestionTheme->extends) && $bDeleteTheme !== false) {
            $aQuestions = Question::model()->findAll(
                "type = :type AND (question_theme_name = '' OR question_theme_name IS NULL) AND parent_qid = :parent_qid",
                [
                    ':type'       => $oQuestionTheme->question_type,
                    ':parent_qid' => 0
                ]
            );
            if (!empty($aQuestions)) {
                // There are questions using this theme. Don't delete it
                $bDeleteTheme = false;
            }
        }

        // if this questiontheme is used, it cannot be deleted
        if (isset($bDeleteTheme) && !$bDeleteTheme) {
            return [
                'error'  => gT('Question theme is used in a Survey and cannot be uninstalled'),
                'result' => false
            ];
        }

        // delete questiontheme if it is not used
        try {
            return [
                'result' => $oQuestionTheme->delete()
            ];
        } catch (CDbException $e) {
            return [
                'error'  => $e->getMessage(),
                'result' => false
            ];
        }
    }

    /**
     * Returns all base question themes as an array indexed by question type
     * (all entries in table question_themes extends='')
     *
     * @return array<string, QuestionTheme>
     */
    public static function findQuestionMetaDataForAllTypes()
    {
        // Getting all question_types which are NOT extended
        /** @var QuestionTheme[] $baseQuestions */
        $baseQuestions = self::model()->findAllByAttributes(['extends' => '']);
        $aQuestionsIndexedByType = [];

        foreach ($baseQuestions as $baseQuestion) {
            $baseQuestion->settings = json_decode((string) $baseQuestion['settings']);
            $aQuestionsIndexedByType[$baseQuestion->question_type] = $baseQuestion;
        }

        return $aQuestionsIndexedByType;
    }

    /**
     * Returns all QuestionTheme settings
     *
     * @param string $question_type
     * @param string $question_theme_name
     * @param string $language
     * @return QuestionTheme
     */
    public static function findQuestionMetaData($question_type, $question_theme_name = null, $language = '')
    {
        if (empty($question_type)) {
            throw new InvalidArgumentException('question_type cannot be empty');
        }

        if (empty($question_theme_name) || $question_theme_name === 'core') {
            $questionTheme = self::model()->base()->findByAttributes(['question_type' => $question_type]);
        } else {
            $criteria = new CDbCriteria();
            $criteria->addCondition('question_type = :question_type AND name = :name');
            $criteria->params = [':question_type' => $question_type, ':name' => $question_theme_name];
            $questionTheme = self::model()->query($criteria, false);
        }

        if (empty($questionTheme)) {
            return self::getDummyInstance($question_type);
        }

        // language settings
        $questionTheme->title = gT($questionTheme->title, "html", $language);
        $questionTheme->group = gT($questionTheme->group, "html", $language);

        // decode settings json
        $questionTheme->settings = json_decode((string) $questionTheme->settings);

        return $questionTheme;
    }

    /**
     * Returns all Question Meta Data for the question type selector
     *
     * @return QuestionTheme[]
     */
    public static function findAllQuestionMetaDataForSelector()
    {
        $criteria = new CDbCriteria();
        //            $criteria->condition = 'extends = :extends';
        $criteria->addCondition('visible = :visible', 'AND');
        $criteria->params = [':visible' => 'Y'];

        /** @var QuestionTheme[] */
        $baseQuestions = self::model()->query($criteria, true);

        if (\PHP_VERSION_ID < 80000) {
            $bOldEntityLoaderState = libxml_disable_entity_loader(true);
        }

        $baseQuestionsModified = [];
        foreach ($baseQuestions as $baseQuestion) {
            //TODO: should be moved into DB column (question_theme_settings table)
            $sQuestionConfigFile = @file_get_contents($baseQuestion->getXmlPath() . DIRECTORY_SEPARATOR . 'config.xml');  // @see: Now that entity loader is disabled, we can't use simplexml_load_file; so we must read the file with file_get_contents and convert it as a string
            if (!$sQuestionConfigFile) {
                /* Not readable file : don't break */
                continue;
            }
            $oQuestionConfig = simplexml_load_string($sQuestionConfigFile);
            $questionEngineData = json_decode(json_encode($oQuestionConfig->engine), true);
            $showAsQuestionType = $questionEngineData['show_as_question_type'];

            // if an extended Question should not be shown as a selectable questiontype skip it
            if (!empty($baseQuestion['extends']) && !$showAsQuestionType) {
                continue;
            }

            // language settings
            $baseQuestion['title'] = gT($baseQuestion['title'], "html");
            $baseQuestion['group'] = gT($baseQuestion['group'], "html");

            // decode settings json
            $baseQuestion['settings'] = json_decode((string) $baseQuestion['settings']);

            $baseQuestion['image_path'] = str_replace(
                '//',
                '/',
                Yii::app()->baseUrl . '/' . $baseQuestion['image_path']
            );
            $baseQuestionsModified[] = $baseQuestion;
        }
        if (\PHP_VERSION_ID < 80000) {
            libxml_disable_entity_loader($bOldEntityLoaderState);
        }

        $baseQuestions = $baseQuestionsModified;

        return $baseQuestions;
    }

    /**
     * @return array
     */
    public static function getQuestionThemeDirectories()
    {
        $questionThemeDirectories[self::THEME_TYPE_CORE] = App()->getConfig('corequestiontypedir') . '/survey/questions/answer';
        $questionThemeDirectories[self::THEME_TYPE_CUSTOM] = App()->getConfig('customquestionthemedir');
        $questionThemeDirectories[self::THEME_TYPE_USER] = App()->getConfig('userquestionthemerootdir');

        return $questionThemeDirectories;
    }

    /**
     * Returns QuestionMetaData Array for use in ->save operations
     *
     * @param array $questionMetaData
     *
     * @return array $questionMetaData
     * @todo Naming is wrong, it does not "get", it "convertTo"
     * @todo Possibly make a DTO for question metadata instead, and implement the ArrayAccess interface or "toArray()"
     */
    public static function getMetaDataArray($questionMetaData)
    {
        $questionMetaData = [
            'name'          => $questionMetaData['name'],
            'visible'       => 'Y',
            'xml_path'      => $questionMetaData['xml_path'],
            'image_path'    => $questionMetaData['image_path'] ?? '',
            'title'         => $questionMetaData['title'] ?? '',
            'creation_date' => date('Y-m-d H:i:s', strtotime((string) $questionMetaData['creationDate'])),
            'author'        => $questionMetaData['author'] ?? '',
            'author_email'  => $questionMetaData['authorEmail'] ?? '',
            'author_url'    => $questionMetaData['authorUrl'] ?? '',
            'copyright'     => $questionMetaData['copyright'] ?? '',
            'license'       => $questionMetaData['license'] ?? '',
            'version'       => $questionMetaData['version'],
            'api_version'   => $questionMetaData['apiVersion'],
            'description'   => $questionMetaData['description'],
            'last_update'   => date('Y-m-d H:i:s'), //todo
            'owner_id'      => 1, //todo
            'theme_type'    => $questionMetaData['type'],
            'question_type' => $questionMetaData['questionType'],
            'core_theme'    => $questionMetaData['coreTheme'],
            'extends'       => $questionMetaData['extends'],
            'group'         => $questionMetaData['group'] ?? '',
            'settings'      => $questionMetaData['settings'] ?? ''
        ];
        return $questionMetaData;
    }

    /**
     * Return the question Theme preview URL
     *
     * @param $sType : type of question
     *
     * @return string : question theme preview URL
     */
    public static function getQuestionThemeImageName($sType = null)
    {
        if ($sType == '*') {
            $preview_filename = 'EQUATION.png';
        } elseif ($sType == ':') {
            $preview_filename = 'COLON.png';
        } elseif ($sType == '|') {
            $preview_filename = 'PIPE.png';
        } elseif (!empty($sType)) {
            $preview_filename = $sType . '.png';
        } else {
            $preview_filename = '.png';
        }

        return $preview_filename;
    }

    /**
     * Returns the table definition for the current Question
     *
     * @param string $name
     * @param string $type
     *
     * @return string mixed
     */
    public static function getAnswerColumnDefinition($name, $type)
    {
        // cache the value between function calls
        static $cacheMemo = [];
        $cacheKey = $name . '_' . $type;
        if (isset($cacheMemo[$cacheKey])) {
            return $cacheMemo[$cacheKey];
        }

        if (empty($name) || $name == 'core') {
            $questionTheme = self::model()->base()->findByAttributes(['question_type' => $type, 'extends' => '']);
        } else {
            $questionTheme = self::model()->findByAttributes([], 'name=:name AND question_type=:question_type', ['name' => $name, 'question_type' => $type]);
        }

        $answerColumnDefinition = '';
        $xmlPath = $questionTheme->getXmlPath();
        if (isset($xmlPath)) {
            if (\PHP_VERSION_ID < 80000) {
                $bOldEntityLoaderState = libxml_disable_entity_loader(true);
            }
            // If xml_path is relative, cwd is assumed to be ROOTDIR.
            // TODO: Make it always relative depending on question theme type (core, custom, user).
            $sQuestionConfigFile = file_get_contents($xmlPath . DIRECTORY_SEPARATOR . 'config.xml');  // @see: Now that entity loader is disabled, we can't use simplexml_load_file; so we must read the file with file_get_contents and convert it as a string
            $oQuestionConfig = simplexml_load_string($sQuestionConfigFile);
            if (isset($oQuestionConfig->metadata->answercolumndefinition)) {
                // TODO: Check json_last_error.
                $answerColumnDefinition = json_decode(json_encode($oQuestionConfig->metadata->answercolumndefinition), true)[0];
            }

            if (\PHP_VERSION_ID < 80000) {
                libxml_disable_entity_loader($bOldEntityLoaderState);
            }
        }

        $cacheMemo[$cacheKey] = $answerColumnDefinition;
        return $answerColumnDefinition;
    }

    /**
     * Returns the Config Path for the selected Question Type base definition
     *
     * @param string $type
     *
     * @return string Path to config XML
     * @throws CException
     */
    public static function getQuestionXMLPathForBaseType($type)
    {
        /** @var QuestionTheme|null */
        $questionTheme = QuestionTheme::model()->findByAttributes([], 'question_type = :question_type AND extends = :extends', ['question_type' => $type, 'extends' => '']);
        if (empty($questionTheme)) {
            throw new \CException("The Database definition for Questiontype: " . $type . " is missing");
        }
        $configXMLPath = App()->getConfig('rootdir') . '/' . $questionTheme->getXmlPath() . '/config.xml';

        return $configXMLPath;
    }

    /**
     * Converts LS3 Question Theme to LS5
     *
     * @param string $sXMLDirectoryPath
     *
     * @return array $success Returns an array with the conversion status
     */
    public static function convertLS3toLS5($sXMLDirectoryPath)
    {
        $sXMLDirectoryPath = str_replace('\\', '/', $sXMLDirectoryPath);
        $sConfigPath = $sXMLDirectoryPath . DIRECTORY_SEPARATOR . 'config.xml';
        if (\PHP_VERSION_ID < 80000) {
            $bOldEntityLoaderState = libxml_disable_entity_loader(true);
        }

        $sQuestionConfigFilePath = $sConfigPath;
        if (!file_exists($sQuestionConfigFilePath)) {
            throw new Exception('Found no config.xml file at ' . $sQuestionConfigFilePath);
        }
        $sQuestionConfigFile = file_get_contents($sQuestionConfigFilePath);  // @see: Now that entity loader is disabled, we can't use simplexml_load_file; so we must read the file with file_get_contents and convert it as a string

        if (!$sQuestionConfigFile) {
            if (\PHP_VERSION_ID < 80000) {
                libxml_disable_entity_loader($bOldEntityLoaderState);
            }
            return $aSuccess = [
                'message' => sprintf(
                    gT('Configuration file %s could not be found or read.'),
                    $sConfigPath
                ),
                'success' => false
            ];
        }

        // replace custom_attributes with attributes
        if (preg_match('/<custom_attributes>/', $sQuestionConfigFile)) {
            $sQuestionConfigFile = preg_replace('/<custom_attributes>/', '<attributes>', $sQuestionConfigFile);
            $sQuestionConfigFile = preg_replace('/<\/custom_attributes>/', '</attributes>', $sQuestionConfigFile);
        };
        $oThemeConfig = simplexml_load_string($sQuestionConfigFile);
        if (\PHP_VERSION_ID < 80000) {
            libxml_disable_entity_loader($bOldEntityLoaderState);
        }

        // get type from core theme
        if (isset($oThemeConfig->metadata->type)) {
            $oThemeConfig->metadata->type = 'question_theme';
        } else {
            $oThemeConfig->metadata->addChild('type', 'question_theme');
        };

        // set compatibility version
        if (
            $oThemeConfig->compatibility->version
            && count($oThemeConfig->compatibility->version) > 1
        ) {
            $length = count($oThemeConfig->compatibility->version);
            $compatibility = $oThemeConfig->addChild('compatibility');
            $compatibility->addChild('version');
            $oThemeConfig->compatibility->version[$length] = '5.0';
        } elseif (
            $oThemeConfig->compatibility->version
            && count($oThemeConfig->compatibility->version) === 1
        ) {
            $oThemeConfig->compatibility->version = '5.0';
        } else {
            $compatibility = $oThemeConfig->addChild('compatibility');
            $compatibility->addChild('version');
            $oThemeConfig->compatibility->version = '5.0';
        }

        $sThemeDirectoryName = self::getThemeDirectoryPath($sQuestionConfigFilePath);
        $sPathToCoreConfigFile = str_replace('\\', '/', App()->getConfig('rootdir') . '/application/views/survey/questions/answer/' . $sThemeDirectoryName . '/config.xml');
        // check if core question theme can be found to fill in missing information
        if (!is_file($sPathToCoreConfigFile)) {
            return $aSuccess = [
                'message' => sprintf(
                    gT("Question theme could not be converted to the latest LimeSurvey version. Reason: No matching core theme with the name %s could be found"),
                    $sThemeDirectoryName
                ),
                'success' => false
            ];
        }
        if (\PHP_VERSION_ID < 80000) {
            $bOldEntityLoaderState = libxml_disable_entity_loader(true);
        }
        $sThemeCoreConfigFile = file_get_contents($sPathToCoreConfigFile);  // @see: Now that entity loader is disabled, we can't use simplexml_load_file; so we must read the file with file_get_contents and convert it as a string
        $oThemeCoreConfig = simplexml_load_string($sThemeCoreConfigFile);
        if (\PHP_VERSION_ID < 80000) {
            libxml_disable_entity_loader($bOldEntityLoaderState);
        }

        // get questiontype from core if it is missing
        if (!isset($oThemeConfig->metadata->questionType)) {
            $oThemeConfig->metadata->addChild('questionType', $oThemeCoreConfig->metadata->questionType);
        };

        // search missing new tags and copy theme from the core theme
        $aNewMetadataTagsToRecoverFromCoreType = ['group', 'subquestions', 'answerscales', 'hasdefaultvalues', 'assessable', 'class'];
        foreach ($aNewMetadataTagsToRecoverFromCoreType as $sMetaTag) {
            if (!isset($oThemeConfig->metadata->$sMetaTag)) {
                $oThemeConfig->metadata->addChild($sMetaTag, $oThemeCoreConfig->metadata->$sMetaTag);
            }
        }

        // write everything back to to xml file
        $oThemeConfig->saveXML($sQuestionConfigFilePath);

        return $aSuccess = [
            'message' => gT('Question theme has been successfully converted to the latest LimeSurvey version.'),
            'success' => true
        ];
    }

    /**
     * Return the question theme custom attributes values
     * -- gets coreAttributes from xml-file
     * -- gets additional attributes from extended theme (if theme is extended)
     * -- gets "own" attributes via plugin
     *
     * @param string  $type question type (this is the attribute 'question_type' in table question_theme)
     * @param string  $sQuestionThemeName : question theme name
     *
     * @return array : the attribute settings for this question type
     * @throws Exception when question type attributes are not available
     */
    public static function getQuestionThemeAttributeValues($type, $sQuestionThemeName = null)
    {
        $aQuestionAttributes = array();
        $xmlConfigPath = self::getQuestionXMLPathForBaseType($type);

        if (\PHP_VERSION_ID < 80000) {
            libxml_disable_entity_loader(false);
        }
        $oCoreConfig = simplexml_load_file($xmlConfigPath);
        $aCoreAttributes = json_decode(json_encode((array)$oCoreConfig), true);
        if (\PHP_VERSION_ID < 80000) {
            libxml_disable_entity_loader(true);
        }
        if (!isset($aCoreAttributes['attributes']['attribute'])) {
            throw new Exception("Question type attributes not available!");
        }
        foreach ($aCoreAttributes['attributes']['attribute'] as $aCoreAttribute) {
            $aQuestionAttributes[$aCoreAttribute['name']] = $aCoreAttribute;
        }

        $additionalAttributes = array();
        if ($sQuestionThemeName !== null) {
            $additionalAttributes = self::getAdditionalAttrFromExtendedTheme($sQuestionThemeName, $type);
        }

        return array_merge(
            $aQuestionAttributes,
            $additionalAttributes,
            QuestionAttribute::getOwnQuestionAttributesViaPlugin()
        );
    }

    /**
     * Gets the additional attributes for an extended theme from xml file.
     * If there are no attributes, an empty array is returned
     *
     * @param string $sQuestionThemeName the question theme name (see table question theme "name")
     * @param string $type   the extended typ (see table question_themes "extends")
     * @return array additional attributes for an extended theme or empty array
     */
    public static function getAdditionalAttrFromExtendedTheme($sQuestionThemeName, $type)
    {
        $additionalAttributes = array();
        if (\PHP_VERSION_ID < 80000) {
            libxml_disable_entity_loader(false);
        }
        $questionTheme = QuestionTheme::model()->findByAttributes([], 'name = :name AND extends = :extends', ['name' => $sQuestionThemeName, 'extends' => $type]);
        if ($questionTheme !== null) {
            $xml_config = simplexml_load_file($questionTheme->getXmlPath() . '/config.xml');
            $attributes = json_decode(json_encode((array)$xml_config->attributes), true);
        }
        if (\PHP_VERSION_ID < 80000) {
            libxml_disable_entity_loader(true);
        }

        if (!empty($attributes)) {
            if (!empty($attributes['attribute']['name'])) {
                // Only one attribute set in config: need an array of attributes
                $attributes['attribute'] = array($attributes['attribute']);
            }
            // Create array of attribute with name as key
            $defaultQuestionAttributeValues = QuestionAttribute::getDefaultSettings();
            foreach ($attributes['attribute'] as $attribute) {
                if (!empty($attribute['name'])) {
                    // inputtype is text by default
                    $additionalAttributes[$attribute['name']] = array_merge($defaultQuestionAttributeValues, $attribute);
                }
            }
        }

        $questionAttributeHelper = new LimeSurvey\Models\Services\QuestionAttributeHelper();
        $additionalAttributes = $questionAttributeHelper->fillMissingCategory($additionalAttributes, gT('Template'));
        $additionalAttributes = $questionAttributeHelper->sanitizeQuestionAttributes($additionalAttributes);

        return $additionalAttributes;
    }

    public static function getThemeDirectoryPath($sQuestionConfigFilePath)
    {
        $sQuestionConfigFilePath = str_replace('\\', '/', (string) $sQuestionConfigFilePath);
        $aMatches = array();
        $sThemeDirectoryName = '';
        if (preg_match('$questions/answer/(.*)/config.xml$', $sQuestionConfigFilePath, $aMatches)) {
            $sThemeDirectoryName = $aMatches[1];
        }
        return $sThemeDirectoryName;
    }

    /**
     * Returns the name of the base question theme for the question type $questionType
     *
     * @param string $questionType
     * @return string|null question theme name or null if no question theme is found
     */
    public function getBaseThemeNameForQuestionType($questionType)
    {
        $questionTheme = $this->base()->findByAttributes(['question_type' => $questionType]);
        if (!empty($questionTheme)) {
            return $questionTheme->name;
        }
    }

    /**
     * Returns the settings attribute decoded
     * @return mixed
     */
    public function getDecodedSettings()
    {
        if (is_object($this->settings)) {
            return $this->settings;
        } else {
            return json_decode($this->settings);
        }
    }

    /**
     * Returns a dummy instance of QuestionTheme, with
     * the question type $questionType.
     *
     * @param string $questionType
     * @return QuestionTheme
     */
    public static function getDummyInstance($questionType)
    {
        $settings = new StdClass();
        $settings->class = '';
        $settings->answerscales = 0;
        $settings->subquestions = 0;

        $questionTheme = new self();
        $questionTheme->title = gT('Question theme error: Missing metadata');
        $questionTheme->name = gT('Question theme error: Missing metadata');
        $questionTheme->question_type = $questionType;
        $questionTheme->settings = $settings;
        return $questionTheme;
    }

    /**
     * Returns the type of question theme (coreQuestion, customCoreTheme, customUserTheme)
     *
     * coreQuestion = Themes shipped with Limesurvey that don't extend other theme
     * customCoreTheme = Themes shipped with Limesurvey that extend other theme
     * customUserTheme = User provided question themes
     *
     * @return string
     */
    public function getThemeType()
    {
        if ($this->core_theme) {
            if (empty($this->extends)) {
                return self::THEME_TYPE_CORE;
            } else {
                return self::THEME_TYPE_CUSTOM;
            }
        } else {
            return self::THEME_TYPE_USER;
        }
    }

    /**
     * Returns the XML path relative to the path configured for the question theme type
     * @return string
     */
    public function getRelativeXmlPath()
    {
        $type = $this->getThemeType();
        $typeDirectory = self::getQuestionThemeDirectoryForType($type);

        // xml_path is supposed to contain the type directory, so we extract the rest of the path
        $relativePath = substr($this->xml_path, strpos($this->xml_path, $typeDirectory) + strlen($typeDirectory));
        $relativePath = ltrim($relativePath, "\\/");
        return $relativePath;
    }

    /**
     * Returns the XML path
     * It may be absolute or relative to the Limesurvey root
     * @return string
     */
    public function getXmlPath()
    {
        return $this->xml_path;
    }

    /**
     * Returns the path for the specified question theme type
     * @param string $themeType
     * @return string
     * @throws Exception if no directory is found for the given type
     */
    public static function getQuestionThemeDirectoryForType($themeType)
    {
        $directories = self::getQuestionThemeDirectories();
        if (!isset($directories[$themeType])) {
            throw new Exception(sprintf(gT("No question theme directory found for theme type '%s'"), $themeType));
        }
        return $directories[$themeType];
    }

    /**
     * Returns the corresponding absolute path, given a relative path and the theme type.
     * @param string $relativePath
     * @param string $themeType
     * @return string
     */
    public static function getAbsolutePathForType($relativePath, $themeType)
    {
        return self::getQuestionThemeDirectoryForType($themeType) . '/' . $relativePath;
    }
}